diff --git a/README.md b/README.md index 53d1a44a..15c2c0cf 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,8 @@ Archived/reference areas: ## Tech stack - **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp -- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5 +- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5, + Leaflet/React Leaflet (brewery maps) - **UI Documentation**: Storybook 10, Vitest browser mode, Playwright - **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq - **Infrastructure**: Docker, Docker Compose diff --git a/docs/architecture.md b/docs/architecture.md index 4f4f6a95..be27d82c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -309,7 +309,12 @@ it, without an ORM's change-tracking or mapping magic through Dapper, with no manual `IDbCommand` parameter binding. The one wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper can't deserialize as a UDT: reads select `CONVERT(varbinary(max), - Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind + Coordinates)` so the value comes back as a plain `byte[]` Dapper can bind. + Proximity search (`GET /api/brewery/locations/nearby`) filters and orders + rows server-side with `Coordinates.STDistance(@Origin) <= @RangeInMetres`, + avoiding a client-side distance calculation over every row. No spatial + index backs the query yet; `schema.sql` sketches one, commented out, for + when the table grows large enough to need it - Referential checks that a stored procedure used to perform (e.g. "does this `CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the repository method, run inside the same transaction as the write @@ -335,11 +340,24 @@ enabled. ```text web/frontend/ ├── app/ -│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider -│ ├── lib/ Auth helpers, schemas, and theme metadata -│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme -│ ├── root.tsx App shell and global providers -│ └── app.css Theme tokens and global styling +│ ├── components/ Shared UI: Navbar, FormField, SubmitButton, ToastProvider, +│ │ RouteErrorState, ClientOnly (defers client-only children, +│ │ for example Leaflet maps, past hydration) +│ ├── features/ One folder per feature slice, each owning its own +│ │ routes/, components/, hooks/, schemas, and server-side +│ │ loader helpers: +│ │ - account profile/dashboard management +│ │ - auth login, register, confirm, logout +│ │ - breweries index/directory/detail routes, Leaflet +│ │ map components, nearby-search resource +│ │ route, distance/address utils +│ │ - catalog beers, beer styles +│ │ - home landing page +│ │ - theme theme switcher/guide +│ ├── hooks/ Shared hooks not tied to one feature, for example useMediaQuery +│ ├── routes.ts Route table (React Router 7 config-based routing) +│ ├── root.tsx App shell and global providers +│ └── app.css Theme tokens and global styling ├── .storybook/ Storybook config and preview setup ├── stories/ Storybook stories for shared UI and themes ├── tests/playwright/ Storybook Playwright coverage @@ -348,9 +366,13 @@ web/frontend/ ### Frontend responsibilities -- Render the auth demo and theme guide routes +- Render brewery discovery routes (`/breweries` index, `/breweries/directory`, + `/breweries/:id`) with Leaflet-powered maps and geolocation-based "nearby" + search +- Render the auth demo, account dashboard, and theme guide routes - Manage cookie-backed website session state -- Call the .NET API for login, registration, token refresh, and confirmation +- Call the .NET API for login, registration, token refresh, confirmation, and + brewery/location data - Provide shared UI building blocks for forms, navigation, themes, and toasts - Supply Storybook documentation and browser-based component verification diff --git a/docs/website/testing.md b/docs/website/testing.md index 356e7317..8a58f011 100644 --- a/docs/website/testing.md +++ b/docs/website/testing.md @@ -164,6 +164,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`. **Features.Breweries.Tests**: - Brewery create/update/delete commands and get-by-id/get-all queries +- Brewery location queries: all locations, and locations within a given range + of a coordinate **Features.UserManagement.Tests**: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..ff320062 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "the-biergarten-app", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql b/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql index 2b6cf591..f3eefa9a 100644 --- a/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql +++ b/web/backend/Database/Database.Migrations/scripts/01-schema/11-BreweryPostLocation.sql @@ -41,14 +41,10 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City ON BreweryPostLocation(CityID); --- To assess when the time comes: - --- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations. - --- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates --- ON BreweryPostLocation(Coordinates) --- USING GEOGRAPHY_GRID --- WITH ( --- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM), --- CELLS_PER_OBJECT = 16 --- ); +CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates + ON BreweryPostLocation(Coordinates) + USING GEOGRAPHY_GRID + WITH ( + GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM), + CELLS_PER_OBJECT = 16 + ); diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs index 46a4efca..384473c1 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPost.cs @@ -21,4 +21,7 @@ public sealed class BreweryPost // Navigation Properties public BreweryPostLocation? Location { get; set; } public List BeerPosts { get; set; } = []; + + // Query-computed + public DistanceInformation? Distance { get; set; } } diff --git a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs index 24222197..a0d4e3e2 100644 --- a/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs +++ b/web/backend/Domain/Domain.Entities/Entities/BreweryPostLocation.cs @@ -2,6 +2,8 @@ namespace Domain.Entities; public sealed record CoordinateData(double Latitude, double Longitude); +/// The distance, as computed by the database, from a query's origin point to a brewery. +public sealed record DistanceInformation(CoordinateData From, double DistanceMetres); public sealed class BreweryPostLocation { 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..f9801023 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDto.cs @@ -32,3 +32,13 @@ public record BreweryDto( byte[]? RowVersion, BreweryLocationDto? Location ); +/// +/// The distance, in metres, from a query's origin point, or for queries with no +/// origin point (e.g. GET /api/brewery/locations). +/// +public record SimplifiedBreweryDto( + Guid BreweryPostId, + string BreweryName, + BreweryLocationDto? Location, + double? DistanceMetres +); diff --git a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs index 4bb74bef..f8c5f792 100644 --- a/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs +++ b/web/backend/Features/Features.Breweries/Dtos/BreweryDtoMapper.cs @@ -40,4 +40,36 @@ 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 + ), + DistanceMetres: brewery.Distance?.DistanceMetres + ); + } } 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..437a0f54 100644 --- a/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs +++ b/web/backend/Features/Features.Breweries/Repository/BreweryRepository.cs @@ -124,6 +124,102 @@ 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, + DistanceRow, + 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, + bpl.Coordinates.STDistance(@Origin) AS DistanceMetres + 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 + """, + (post, location, city, stateProvince, country, distance) => + MapBreweryRowWithDistance(post, location, city, stateProvince, country, distance, coords), + param: new + { + coords.Latitude, + coords.Longitude, + RangeInMetres = rangeInMetres + }, + splitOn: "BreweryPostLocationID,CityID,StateProvinceID,CountryID,DistanceMetres" + ); + } + + /// + 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 +230,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 +250,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 +308,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 +399,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 +435,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 +586,29 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory) post.Location = location; return post; } -} + + /// Dapper multi-mapping row for the DistanceMetres column computed by a range query. + private sealed class DistanceRow + { + public double DistanceMetres { get; set; } + } + + /// + /// Composes a joined row via , then attaches the DB-computed distance + /// from as the result's . + /// + private static BreweryPost MapBreweryRowWithDistance( + BreweryPost post, + BreweryPostLocation location, + City city, + StateProvince stateProvince, + Country country, + DistanceRow distance, + CoordinateData origin + ) + { + BreweryPost mapped = MapBreweryRow(post, location, city, stateProvince, country); + mapped.Distance = new DistanceInformation(origin, distance.DistanceMetres); + return mapped; + } +} \ 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/.storybook/main.ts b/web/frontend/.storybook/main.ts index c2600398..bf9b9d3e 100644 --- a/web/frontend/.storybook/main.ts +++ b/web/frontend/.storybook/main.ts @@ -1,19 +1,7 @@ import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { - stories: [ - '../stories/Configure.mdx', - '../stories/SubmitButton.stories.tsx', - '../stories/FormField.stories.tsx', - '../stories/SectionCard.stories.tsx', - '../stories/UsernameSection.stories.tsx', - '../stories/PasswordSection.stories.tsx', - '../stories/LoginForm.stories.tsx', - '../stories/RegisterForm.stories.tsx', - '../stories/Navbar.stories.tsx', - '../stories/Toast.stories.tsx', - '../stories/Themes.stories.tsx', - ], + stories: ['../stories/Configure.mdx', '../app/**/*.stories.tsx'], addons: [ '@chromatic-com/storybook', '@storybook/addon-vitest', diff --git a/web/frontend/app/components/ClientOnly.tsx b/web/frontend/app/components/ClientOnly.tsx new file mode 100644 index 00000000..ace1eaf5 --- /dev/null +++ b/web/frontend/app/components/ClientOnly.tsx @@ -0,0 +1,21 @@ +import { useSyncExternalStore, type ReactNode } from 'react'; + +interface ClientOnlyProps { + children: () => ReactNode; + fallback?: ReactNode; +} + +const noop = () => undefined; +const emptySubscribe = () => noop; + +const ClientOnly = ({ children, fallback = null }: ClientOnlyProps) => { + const mounted = useSyncExternalStore( + emptySubscribe, + () => true, + () => false, + ); + + return mounted ? children() : fallback; +}; + +export default ClientOnly; diff --git a/web/frontend/stories/FormField.stories.tsx b/web/frontend/app/components/ui/forms/FormField.stories.tsx similarity index 97% rename from web/frontend/stories/FormField.stories.tsx rename to web/frontend/app/components/ui/forms/FormField.stories.tsx index 10e8846d..13594240 100644 --- a/web/frontend/stories/FormField.stories.tsx +++ b/web/frontend/app/components/ui/forms/FormField.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, within } from 'storybook/test'; -import FormField from '../app/components/ui/forms/FormField'; +import FormField from './FormField'; const formFieldDescription = `Reusable labeled input for Biergarten forms. This page shows guided, error, and password states so you can review label spacing, helper text, validation messaging, and ARIA behavior in the same card layout used across the app.`; diff --git a/web/frontend/stories/SubmitButton.stories.tsx b/web/frontend/app/components/ui/forms/SubmitButton.stories.tsx similarity index 95% rename from web/frontend/stories/SubmitButton.stories.tsx rename to web/frontend/app/components/ui/forms/SubmitButton.stories.tsx index ac4932ac..d2ffa558 100644 --- a/web/frontend/stories/SubmitButton.stories.tsx +++ b/web/frontend/app/components/ui/forms/SubmitButton.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, within } from 'storybook/test'; -import SubmitButton from '../app/components/ui/forms/SubmitButton'; +import SubmitButton from './SubmitButton'; const submitButtonDescription = `Shared submit action for Biergarten forms. These stories cover the idle, loading, and custom-width states so you can verify button copy, disabled behavior during submission, and theme styling without wiring up a full form flow.`; diff --git a/web/frontend/stories/Navbar.stories.tsx b/web/frontend/app/components/ui/navbar/Navbar.stories.tsx similarity index 97% rename from web/frontend/stories/Navbar.stories.tsx rename to web/frontend/app/components/ui/navbar/Navbar.stories.tsx index 253c050c..a5744a24 100644 --- a/web/frontend/stories/Navbar.stories.tsx +++ b/web/frontend/app/components/ui/navbar/Navbar.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, userEvent, within } from 'storybook/test'; -import Navbar from '../app/components/ui/navbar/Navbar'; +import Navbar from './Navbar'; const navbarDescription = `Top-level navigation for the Biergarten website. These stories cover guest, authenticated, and mobile states so you can review branding, route visibility, account menu behavior, and responsive collapse without leaving Storybook.`; diff --git a/web/frontend/stories/Toast.stories.tsx b/web/frontend/app/components/ui/toast/Toast.stories.tsx similarity index 92% rename from web/frontend/stories/Toast.stories.tsx rename to web/frontend/app/components/ui/toast/Toast.stories.tsx index 6e33729a..1f158b8c 100644 --- a/web/frontend/stories/Toast.stories.tsx +++ b/web/frontend/app/components/ui/toast/Toast.stories.tsx @@ -1,12 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { expect, screen, userEvent, within } from 'storybook/test'; -import ToastProvider from '../app/components/ui/toast/ToastProvider'; -import { - dismissToasts, - showErrorToast, - showInfoToast, - showSuccessToast, -} from '../app/components/ui/toast/toast'; +import ToastProvider from './ToastProvider'; +import { dismissToasts, showErrorToast, showInfoToast, showSuccessToast } from './toast'; const toastDescription = `Theme-aware toast feedback built on react-hot-toast. Use this page to trigger success, error, and info messages, check icon contrast and surface styling, and confirm notifications feel consistent across Biergarten themes.`; diff --git a/web/frontend/stories/PasswordSection.stories.tsx b/web/frontend/app/features/account/components/PasswordSection.stories.tsx similarity index 93% rename from web/frontend/stories/PasswordSection.stories.tsx rename to web/frontend/app/features/account/components/PasswordSection.stories.tsx index 7377fddd..7f532a3d 100644 --- a/web/frontend/stories/PasswordSection.stories.tsx +++ b/web/frontend/app/features/account/components/PasswordSection.stories.tsx @@ -2,9 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router'; import { expect, userEvent, within } from 'storybook/test'; -import PasswordSection from '../app/features/account/components/PasswordSection'; -import { updatePasswordSchema } from '../app/features/account/schemas'; -import type { ActionResult } from '../app/features/account/types'; +import PasswordSection from './PasswordSection'; +import { updatePasswordSchema } from '../schemas'; +import type { ActionResult } from '../types'; const passwordSectionDescription = `Renders the real \`PasswordSection\` component (the account page's collapsible password-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updatePasswordSchema\` (zod) cross-field rules. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`; @@ -14,7 +14,11 @@ const PasswordSectionRoute = () => { return (
- setOpen((value) => !value)} result={result} /> + setOpen((value) => !value)} + result={result} + />
); }; diff --git a/web/frontend/stories/SectionCard.stories.tsx b/web/frontend/app/features/account/components/SectionCard.stories.tsx similarity index 97% rename from web/frontend/stories/SectionCard.stories.tsx rename to web/frontend/app/features/account/components/SectionCard.stories.tsx index fb7cf6e6..f71d08ef 100644 --- a/web/frontend/stories/SectionCard.stories.tsx +++ b/web/frontend/app/features/account/components/SectionCard.stories.tsx @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { Lock } from 'iconoir-react'; import { useState } from 'react'; import { expect, userEvent, within } from 'storybook/test'; -import SectionCard from '../app/features/account/components/SectionCard'; +import SectionCard from './SectionCard'; const sectionCardDescription = `Collapsible card used on the account settings page to group a labeled form behind a toggle. These stories cover the closed and open states, plus the toggle interaction, so you can review spacing, icon alignment, and disclosure behavior in isolation.`; diff --git a/web/frontend/stories/UsernameSection.stories.tsx b/web/frontend/app/features/account/components/UsernameSection.stories.tsx similarity index 94% rename from web/frontend/stories/UsernameSection.stories.tsx rename to web/frontend/app/features/account/components/UsernameSection.stories.tsx index aa485315..5c26dca5 100644 --- a/web/frontend/stories/UsernameSection.stories.tsx +++ b/web/frontend/app/features/account/components/UsernameSection.stories.tsx @@ -2,9 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router'; import { expect, userEvent, within } from 'storybook/test'; -import UsernameSection from '../app/features/account/components/UsernameSection'; -import { updateUsernameSchema } from '../app/features/account/schemas'; -import type { ActionResult } from '../app/features/account/types'; +import UsernameSection from './UsernameSection'; +import { updateUsernameSchema } from '../schemas'; +import type { ActionResult } from '../types'; const usernameSectionDescription = `Renders the real \`UsernameSection\` component (the account page's collapsible username-change card) behind a memory router so its \`useSubmit\`/\`useNavigation\` wiring runs against a fake action, exercising the real \`updateUsernameSchema\` (zod) validation. On success the real action redirects back to the account page with a toast message instead of returning data - here it redirects to a marker route so the redirect itself is the observable outcome, matching how the real route behaves.`; diff --git a/web/frontend/app/features/account/hooks/useSectionError.ts b/web/frontend/app/features/account/hooks/useSectionError.ts index b34274b7..9a574ff9 100644 --- a/web/frontend/app/features/account/hooks/useSectionError.ts +++ b/web/frontend/app/features/account/hooks/useSectionError.ts @@ -2,7 +2,10 @@ import { useEffect } from 'react'; import { showErrorToast } from '../../../components/ui/toast/toast'; import type { ActionResult } from '../types'; -export const useSectionError = (result: ActionResult | undefined, intent: ActionResult['intent']) => { +export const useSectionError = ( + result: ActionResult | undefined, + intent: ActionResult['intent'], +) => { useEffect(() => { if (!result || result.intent !== intent) return; showErrorToast(result.error); diff --git a/web/frontend/stories/LoginForm.stories.tsx b/web/frontend/app/features/auth/components/LoginForm.stories.tsx similarity index 94% rename from web/frontend/stories/LoginForm.stories.tsx rename to web/frontend/app/features/auth/components/LoginForm.stories.tsx index 322d8b0a..0973bacc 100644 --- a/web/frontend/stories/LoginForm.stories.tsx +++ b/web/frontend/app/features/auth/components/LoginForm.stories.tsx @@ -3,8 +3,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { expect, userEvent, within } from 'storybook/test'; -import LoginForm from '../app/features/auth/components/LoginForm'; -import { loginSchema, type LoginSchema } from '../app/features/auth/schemas'; +import LoginForm from './LoginForm'; +import { loginSchema, type LoginSchema } from '../schemas'; const loginFormDescription = `Renders the real \`LoginForm\` component wired to the real \`loginSchema\` (zod) through the same react-hook-form setup used on the login page, so these stories validate actual client-side validation behavior rather than a re-implemented form.`; diff --git a/web/frontend/stories/RegisterForm.stories.tsx b/web/frontend/app/features/auth/components/RegisterForm.stories.tsx similarity index 97% rename from web/frontend/stories/RegisterForm.stories.tsx rename to web/frontend/app/features/auth/components/RegisterForm.stories.tsx index 6a60d68d..d8dcafe0 100644 --- a/web/frontend/stories/RegisterForm.stories.tsx +++ b/web/frontend/app/features/auth/components/RegisterForm.stories.tsx @@ -3,8 +3,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { expect, fireEvent, userEvent, within } from 'storybook/test'; -import RegisterForm from '../app/features/auth/components/RegisterForm'; -import { registerSchema, type RegisterSchema } from '../app/features/auth/schemas'; +import RegisterForm from './RegisterForm'; +import { registerSchema, type RegisterSchema } from '../schemas'; const registerFormDescription = `Renders the real \`RegisterForm\` component wired to the real \`registerSchema\` (zod) through the same react-hook-form setup used on the register page, including its cross-field rule that the confirmation must match the password, so these stories validate actual client-side validation behavior rather than a re-implemented form.`; diff --git a/web/frontend/app/features/breweries/BREWERY_HANDOFF.md b/web/frontend/app/features/breweries/BREWERY_HANDOFF.md new file mode 100644 index 00000000..5f9c45ba --- /dev/null +++ b/web/frontend/app/features/breweries/BREWERY_HANDOFF.md @@ -0,0 +1,101 @@ +# Brewery feature: data requirements + +Covers `/breweries` (`routes/breweries.tsx`) and `/breweries/:id` +(`routes/brewery-detail.tsx`). Both routes are built and live. This document +lists what's real vs. filler, and what backend work closes each gap. + +## Status legend + +- ✅ **Available**: real data, wired up. +- ⚠️ **Partial**: endpoint exists but is missing a field the design needs. +- ❌ **Missing**: no backend support; page shows filler or local-only state. + +## Breweries index (`/breweries`) + +### Featured brewery + +| Field | Status | Source | Gap | +| ------------------------------------ | ------ | --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Name, description, address, location | ✅ | `GET /api/brewery` (`BreweryDto`) | | +| "Editorially featured" selection | ❌ | none (defaults to newest brewery) | No `IsFeatured` flag or rotation concept. Needs a field + admin toggle, or a scheduled rotation query. | +| Founded year | ❌ | none | No founding-date column (distinct from `CreatedAt`, which is post-creation time). | +| Beers listed count | ❌ | none | Blocked on the Beers gap below. | +| House style | ❌ | none | No style/tag field, and no aggregate over a brewery's beers' styles. | + +### Breweries near you + +| Field | Status | Source | Gap | +| ------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| Nearby breweries by coordinates + radius | ✅ | `GET /api/brewery/locations/nearby?latitude&longitude&rangeInMetres` | | +| Distance from search center | ✅ | `SimplifiedBreweryDto.DistanceMetres`, computed server-side via `geography::STDistance` | | +| One-line note per brewery | ❌ | none | `SimplifiedBreweryDto` has no description-like field. Needs a tagline field, or a truncated `Description`. | +| User's current location | ✅ | Browser Geolocation API | | +| Fallback region (before permission granted / on denial) | ⚠️ | Falls back to the featured brewery's coordinates | Works, but isn't a deliberate default. A real deployment wants a configured default (e.g. per-tenant HQ city). | +| Manual location entry ("Change location") | ❌ | Re-requests browser geolocation only | No geocoding/place-search endpoint to resolve a typed address to coordinates. | +| Search radius | ✅ | Client-side state (10–150 km slider), passed as `rangeInMetres` | | + +### Recently added + +✅ Fully real: `GET /api/brewery`, already sorted newest-first. No gaps. + +### Directory CTA + +| Field | Status | Source | Gap | +| --------------------------------------- | ------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| Total partner brewery count | ❌ | Filler number (`128`) | No count endpoint. Needs `GET /api/brewery/count` rather than fetching the full list to measure it. | +| Filter by country / region / beer style | ❌ | Copy only | Describes the future directory page's capability, not a data need of this page. Implies the directory will need filter params. | + +## Brewery detail (`/breweries/:id`) + +### Header, location, dates + +| Field | Status | Source | +| --------------------------------------- | ------ | ------------------------------------------------------------------------------------ | +| Name, description, address, coordinates | ✅ | `GET /api/brewery/{id}` (`BreweryDto`) | +| Created / updated dates | ✅ | `BreweryDto` | +| Founded year, brewery type, website | ❌ | `FILLER_BREWERY_META`: `BreweryPost` has no `FoundedAt`, `Type`, or `Website` column | + +### Beers, style breakdown + +| Field | Status | Source | Gap | +| ---------------------------- | ------ | --------------------------------------- | ----------------------------------------------------------------------------- | +| Beers belonging to a brewery | ❌ | `FILLER_BEERS` | See "Beers feature is empty" below. | +| Beer style breakdown | ❌ | Derived client-side from `FILLER_BEERS` | Blocked on the same gap; otherwise an aggregate query over a brewery's beers. | + +### Likes, ratings, comments + +| Field | Status | Source | Gap | +| --------------------------- | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Brewery like/unlike + count | ❌ | Local component state | No like concept in the schema. Needs a join table (composite `BreweryPostId`/`UserAccountId`, like `UserFollow`). | +| Your rating (1–5) + average | ❌ | Local component state | No brewery rating table/endpoint. | +| Comments (list + post) | ❌ | `FILLER_COMMENTS`, local state | No `BreweryPostComment` table. `BeerPostComment` is a close precedent (comment + 1–5 `CHECK`-constrained rating + FKs) but is unused and targets `BeerPost`, not `BreweryPost`. | +| Per-comment like toggle | ❌ | Local component state | No "like a comment" concept; needs a join table alongside a new `BreweryPostComment` table. | + +## Cross-cutting: the Beers feature is empty + +`Features.Beers` (backend) has a `.csproj` and nothing else: no query +handlers, no controller, no DTOs. `BeerPost` exists as a domain entity (with +`BrewedById` linking to `BreweryPost`) but nothing exposes it over the API. +This blocks: + +- "Beers listed" count and "House style" on the index page's featured card. +- The beer list and style breakdown on the detail page. +- Any future beer-style filter on the directory. + +## Summary of backend work + +1. `IsFeatured` flag (or rotation query) on `BreweryPost`. +2. `FoundedAt`/`FoundedYear`, `Type`, `Website` columns on `BreweryPost` (+ `BreweryDto`). +3. A tagline/note field on `BreweryPost`, surfaced through `SimplifiedBreweryDto`. +4. Stand up `Features.Beers`: a query + controller route for a brewery's beers, following the `Features.Breweries` vertical-slice pattern. Unblocks beer counts, style breakdown, and style filters. +5. `GET /api/brewery/count`. +6. A geocoding/place-search endpoint for manual location entry. +7. `BreweryPostLike` join table + command/query/controller (toggle, read count/liked-by-current-user); public GET, JWT-required toggle, mirroring `BreweryController`'s `[AllowAnonymous]`-on-reads pattern. +8. `BreweryPostRating` table (1–5, `CHECK`-constrained like `BeerPostComment.Rating`) + aggregate query for average/count. +9. `BreweryPostComment` table (mirroring `BeerPostComment.sql`, FK'd to `BreweryPost`) + `BreweryPostCommentLike` join table, plus command/query/controller for both. +10. (Product decision, not code) a configured default region to fall back to instead of the featured brewery's coordinates. + +Once these land, swap `FILLER_BEERS` / `FILLER_COMMENTS` / `FILLER_BREWERY_META` +(`utils/filler-brewery-detail.ts`) and the local `useState` in +`brewery-detail.tsx` for loader data and `authorizedRequest`-based mutations +(see `auth.server.ts`). diff --git a/web/frontend/app/features/breweries/breweries.server.ts b/web/frontend/app/features/breweries/breweries.server.ts index c10612d1..f02bec01 100644 --- a/web/frontend/app/features/breweries/breweries.server.ts +++ b/web/frontend/app/features/breweries/breweries.server.ts @@ -7,13 +7,24 @@ interface ApiResponse { payload: T; } +export interface BreweryCoordinates { + latitude: number; + longitude: number; +} + export interface BreweryLocation { breweryPostLocationId: string; breweryPostId: string; cityId: string; + cityName: string; + stateProvinceName: string; + stateProvinceCode: string; + countryName: string; + countryCode: string; addressLine1: string; addressLine2: string | null; postalCode: string; + coordinates: BreweryCoordinates | null; } export interface Brewery { @@ -26,6 +37,13 @@ export interface Brewery { location: BreweryLocation | null; } +export interface SimplifiedBrewery { + breweryPostId: string; + breweryName: string; + location: BreweryLocation | null; + distanceMetres: number | null; +} + const fetchApi = async (path: string): Promise => { try { return await fetch(`${API_BASE_URL}${path}`); @@ -55,6 +73,44 @@ 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 getBreweryLocationsNearby = async ( + latitude: number, + longitude: number, + rangeInMetres: number, +): Promise => { + const params = new URLSearchParams({ + latitude: String(latitude), + longitude: String(longitude), + rangeInMetres: String(rangeInMetres), + }); + + const res = await fetchApi(`/api/brewery/locations/nearby?${params}`); + + if (!res.ok) { + throw data(`Failed to load nearby breweries (${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/components/BreweryCard.tsx b/web/frontend/app/features/breweries/components/BreweryCard.tsx deleted file mode 100644 index b1a1a96f..00000000 --- a/web/frontend/app/features/breweries/components/BreweryCard.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Link } from 'react-router'; -import type { Brewery } from '../breweries.server'; - -interface BreweryCardProps { - brewery: Brewery; -} - -const BreweryCard = ({ brewery }: BreweryCardProps) => ( - -
-

{brewery.breweryName}

-

{brewery.description}

- {brewery.location && ( -

- {brewery.location.addressLine1} - {brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''} -

- )} -
- -); - -export default BreweryCard; diff --git a/web/frontend/app/features/breweries/components/detail/BeerListCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/BeerListCard.stories.tsx new file mode 100644 index 00000000..cd09d3ee --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BeerListCard.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import BeerListCard from './BeerListCard'; +import { FILLER_BEERS } from '../../utils/filler-brewery-detail'; + +const beerListCardDescription = `A brewery's beer list on the detail page: name/description, style badge, ABV, and star rating per row. Currently filler pending a beers-by-brewery endpoint — see BREWERY_HANDOFF.md.`; + +const meta = { + title: 'Breweries/BeerListCard', + component: BeerListCard, + tags: ['autodocs'], + args: { + beers: FILLER_BEERS, + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: beerListCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('heading', { name: 'Beers (9)' })).toBeInTheDocument(); + await expect(canvas.getByRole('link', { name: /view all beers/i })).toHaveAttribute( + 'href', + '/beers', + ); + await expect(canvas.getByText('Switchyard IPA')).toBeInTheDocument(); + await expect(canvas.getByText('6.8% ABV')).toBeInTheDocument(); + }, +}; + +export const SingleBeer: Story = { + args: { beers: FILLER_BEERS.slice(0, 1) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('heading', { name: 'Beers (1)' })).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/BeerListCard.tsx b/web/frontend/app/features/breweries/components/detail/BeerListCard.tsx new file mode 100644 index 00000000..3c0b48b6 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BeerListCard.tsx @@ -0,0 +1,42 @@ +import { Link } from 'react-router'; +import StarRating from '../shared/StarRating'; +import type { FillerBeer } from '../../utils/filler-brewery-detail'; + +interface BeerListCardProps { + beers: FillerBeer[]; +} + +const BeerListCard = ({ beers }: BeerListCardProps) => ( +
+
+
+

Beers ({beers.length})

+ + View all beers + +
+
+ {beers.map((beer) => ( +
+
+ {beer.name} + {beer.description} +
+ + {beer.style} + + + {beer.abv.toFixed(1)}% ABV + + +
+ ))} +
+
+
+); + +export default BeerListCard; diff --git a/web/frontend/app/features/breweries/components/detail/BeerStyleBreakdownCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/BeerStyleBreakdownCard.stories.tsx new file mode 100644 index 00000000..23e18bda --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BeerStyleBreakdownCard.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import BeerStyleBreakdownCard from './BeerStyleBreakdownCard'; +import { FILLER_BEERS } from '../../utils/filler-brewery-detail'; + +const beerStyleBreakdownCardDescription = `Sidebar "Beer styles" card: per-style progress bars, sorted by count descending, derived client-side from a brewery's beer list.`; + +const meta = { + title: 'Breweries/BeerStyleBreakdownCard', + component: BeerStyleBreakdownCard, + tags: ['autodocs'], + args: { + beers: FILLER_BEERS, + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: beerStyleBreakdownCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Beer styles')).toBeInTheDocument(); + await expect(canvas.getByText('IPA')).toBeInTheDocument(); + await expect(canvas.getByText('Sour')).toBeInTheDocument(); + }, +}; + +export const SingleStyle: Story = { + args: { + beers: FILLER_BEERS.filter((beer) => beer.style === 'IPA'), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('IPA')).toBeInTheDocument(); + await expect(canvas.queryByText('Sour')).not.toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/BeerStyleBreakdownCard.tsx b/web/frontend/app/features/breweries/components/detail/BeerStyleBreakdownCard.tsx new file mode 100644 index 00000000..03165626 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BeerStyleBreakdownCard.tsx @@ -0,0 +1,33 @@ +import SidebarCard from '../shared/SidebarCard'; +import type { FillerBeer } from '../../utils/filler-brewery-detail'; + +interface BeerStyleBreakdownCardProps { + beers: FillerBeer[]; +} + +const BeerStyleBreakdownCard = ({ beers }: BeerStyleBreakdownCardProps) => { + const counts = new Map(); + beers.forEach((beer) => counts.set(beer.style, (counts.get(beer.style) ?? 0) + 1)); + const maxCount = Math.max(...counts.values()); + const styles = [...counts.entries()].sort((a, b) => b[1] - a[1]); + + return ( + + {styles.map(([style, count]) => ( +
+
+ {style} + {count} +
+ +
+ ))} +
+ ); +}; + +export default BeerStyleBreakdownCard; diff --git a/web/frontend/app/features/breweries/components/detail/BreweryDetailsCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/BreweryDetailsCard.stories.tsx new file mode 100644 index 00000000..8fc064a5 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BreweryDetailsCard.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import BreweryDetailsCard from './BreweryDetailsCard'; + +const breweryDetailsCardDescription = `Sidebar "Details" card on the brewery detail page: founded year, brewery type, and beer count. Founded year and type are currently filler pending backend support — see BREWERY_HANDOFF.md.`; + +const meta = { + title: 'Breweries/BreweryDetailsCard', + component: BreweryDetailsCard, + tags: ['autodocs'], + args: { + foundedYear: 2014, + type: 'Microbrewery', + beerCount: 9, + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: breweryDetailsCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Founded')).toBeInTheDocument(); + await expect(canvas.getByText('2014')).toBeInTheDocument(); + await expect(canvas.getByText('Microbrewery')).toBeInTheDocument(); + await expect(canvas.getByText('9')).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/BreweryDetailsCard.tsx b/web/frontend/app/features/breweries/components/detail/BreweryDetailsCard.tsx new file mode 100644 index 00000000..34d0fcf7 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BreweryDetailsCard.tsx @@ -0,0 +1,24 @@ +import SidebarCard from '../shared/SidebarCard'; + +interface BreweryDetailsCardProps { + foundedYear: number; + type: string; + beerCount: number; +} + +const DetailRow = ({ label, value }: { label: string; value: string | number }) => ( +
+ {label} + {value} +
+); + +const BreweryDetailsCard = ({ foundedYear, type, beerCount }: BreweryDetailsCardProps) => ( + + + + + +); + +export default BreweryDetailsCard; diff --git a/web/frontend/app/features/breweries/components/detail/BreweryHeaderCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/BreweryHeaderCard.stories.tsx new file mode 100644 index 00000000..844fb352 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BreweryHeaderCard.stories.tsx @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import BreweryHeaderCard from './BreweryHeaderCard'; +import type { Brewery, BreweryLocation } from '../../breweries.server'; + +const breweryHeaderCardDescription = `Top card on the brewery detail page: eyebrow + "Est." badge, name, address, like button, average rating, and description. Founded year and average rating are currently filler pending backend support — see BREWERY_HANDOFF.md.`; + +const portlandLocation: BreweryLocation = { + breweryPostLocationId: 'loc-1', + breweryPostId: 'brewery-1', + cityId: 'city-1', + cityName: 'Portland', + stateProvinceName: 'Oregon', + stateProvinceCode: 'OR', + countryName: 'United States', + countryCode: 'US', + addressLine1: '123 Hop Street', + addressLine2: null, + postalCode: '97201', + coordinates: { latitude: 45.5152, longitude: -122.6784 }, +}; + +const brewery: Brewery = { + breweryPostId: 'brewery-1', + postedById: 'user-1', + breweryName: 'Cascade Hollow Brewing', + description: + 'A small-batch brewery known for hazy IPAs and barrel-aged sours, tucked into an old warehouse district.', + createdAt: '2024-03-01T12:00:00.000Z', + updatedAt: null, + location: portlandLocation, +}; + +const meta = { + title: 'Breweries/BreweryHeaderCard', + component: BreweryHeaderCard, + tags: ['autodocs'], + args: { + brewery, + foundedYear: 2014, + avgRating: 4, + ratingsCount: 76, + onToggleLike: fn(), + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: breweryHeaderCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Unliked: Story = { + args: { liked: false, likeCount: 127 }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByRole('heading', { name: 'Cascade Hollow Brewing', level: 1 }), + ).toBeInTheDocument(); + const likeButton = canvas.getByRole('button', { name: /like · 127/i }); + await userEvent.click(likeButton); + await expect(args.onToggleLike).toHaveBeenCalledOnce(); + }, +}; + +export const Liked: Story = { + args: { liked: true, likeCount: 128 }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('button', { name: /liked · 128/i })).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/BreweryHeaderCard.tsx b/web/frontend/app/features/breweries/components/detail/BreweryHeaderCard.tsx new file mode 100644 index 00000000..893ddee7 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BreweryHeaderCard.tsx @@ -0,0 +1,70 @@ +import { Heart, HeartSolid, MapPin } from 'iconoir-react'; +import StarRating from '../shared/StarRating'; +import { formatBreweryAddress } from '../../utils/format-address'; +import type { Brewery } from '../../breweries.server'; + +interface BreweryHeaderCardProps { + brewery: Brewery; + foundedYear: number; + liked: boolean; + likeCount: number; + avgRating: number; + ratingsCount: number; + onToggleLike: () => void; +} + +const BreweryHeaderCard = ({ + brewery, + foundedYear, + liked, + likeCount, + avgRating, + ratingsCount, + onToggleLike, +}: BreweryHeaderCardProps) => ( +
+
+
+
+ + Brewery + + + Est. {foundedYear} + +
+

{brewery.breweryName}

+ {brewery.location && ( +

+

+ )} +
+
+ +
+ + {avgRating.toFixed(1)} + ({ratingsCount} ratings) +
+
+

+ {brewery.description} +

+
+
+); + +export default BreweryHeaderCard; diff --git a/web/frontend/app/features/breweries/components/detail/BreweryLocationMap.stories.tsx b/web/frontend/app/features/breweries/components/detail/BreweryLocationMap.stories.tsx new file mode 100644 index 00000000..d6436735 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BreweryLocationMap.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import BreweryLocationMap from './BreweryLocationMap'; + +const breweryLocationMapDescription = `Small fixed single-pin Leaflet map used by the brewery detail page's Location sidebar card. Smoke-render only - this hits the real OpenStreetMap tile server in the browser test run.`; + +const meta = { + title: 'Breweries/BreweryLocationMap', + component: BreweryLocationMap, + parameters: { + layout: 'padded', + docs: { + description: { + component: breweryLocationMapDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + latitude: 45.5152, + longitude: -122.6784, + label: 'Cascade Hollow Brewing — 123 Hop Street', + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/BreweryLocationMap.tsx b/web/frontend/app/features/breweries/components/detail/BreweryLocationMap.tsx new file mode 100644 index 00000000..2afa317a --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/BreweryLocationMap.tsx @@ -0,0 +1,41 @@ +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import L, { type LatLngTuple } from 'leaflet'; +import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; +import iconUrl from 'leaflet/dist/images/marker-icon.png'; +import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; +import 'leaflet/dist/leaflet.css'; + +delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl; +L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl }); + +interface BreweryLocationMapProps { + latitude: number; + longitude: number; + label: string; +} + +const ZOOM = 15; + +const BreweryLocationMap = ({ latitude, longitude, label }: BreweryLocationMapProps) => { + const position: LatLngTuple = [latitude, longitude]; + + return ( + + + + {label} + + + ); +}; + +export default BreweryLocationMap; diff --git a/web/frontend/app/features/breweries/components/detail/CommunityStatsCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/CommunityStatsCard.stories.tsx new file mode 100644 index 00000000..b9da5a42 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/CommunityStatsCard.stories.tsx @@ -0,0 +1,37 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import CommunityStatsCard from './CommunityStatsCard'; + +const communityStatsCardDescription = `Sidebar "Community" card: live likes/ratings/comments counts, kept in sync with the header, your-rating, and comments cards on the brewery detail page.`; + +const meta = { + title: 'Breweries/CommunityStatsCard', + component: CommunityStatsCard, + tags: ['autodocs'], + args: { + likeCount: 127, + ratingsCount: 76, + commentCount: 3, + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: communityStatsCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Likes')).toBeInTheDocument(); + await expect(canvas.getByText('127')).toBeInTheDocument(); + await expect(canvas.getByText('76')).toBeInTheDocument(); + await expect(canvas.getByText('3')).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/CommunityStatsCard.tsx b/web/frontend/app/features/breweries/components/detail/CommunityStatsCard.tsx new file mode 100644 index 00000000..9835972a --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/CommunityStatsCard.tsx @@ -0,0 +1,24 @@ +import SidebarCard from '../shared/SidebarCard'; + +interface CommunityStatsCardProps { + likeCount: number; + ratingsCount: number; + commentCount: number; +} + +const StatRow = ({ label, value }: { label: string; value: number }) => ( +
+ {label} + {value} +
+); + +const CommunityStatsCard = ({ likeCount, ratingsCount, commentCount }: CommunityStatsCardProps) => ( + + + + + +); + +export default CommunityStatsCard; diff --git a/web/frontend/app/features/breweries/components/detail/LocationCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/LocationCard.stories.tsx new file mode 100644 index 00000000..01c3832c --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/LocationCard.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, within } from 'storybook/test'; +import LocationCard from './LocationCard'; +import type { BreweryLocation } from '../../breweries.server'; + +const locationCardDescription = `Sidebar "Location" card: an interactive single-pin map (when coordinates are available), address, and website link. Smoke-render only for the map portion - this hits the real OpenStreetMap tile server in the browser test run.`; + +const portlandLocation: BreweryLocation = { + breweryPostLocationId: 'loc-1', + breweryPostId: 'brewery-1', + cityId: 'city-1', + cityName: 'Portland', + stateProvinceName: 'Oregon', + stateProvinceCode: 'OR', + countryName: 'United States', + countryCode: 'US', + addressLine1: '123 Hop Street', + addressLine2: null, + postalCode: '97201', + coordinates: { latitude: 45.5152, longitude: -122.6784 }, +}; + +const meta = { + title: 'Breweries/LocationCard', + component: LocationCard, + tags: ['autodocs'], + args: { + breweryName: 'Cascade Hollow Brewing', + website: 'cascadehollow.example', + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: locationCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const WithCoordinates: Story = { + args: { location: portlandLocation }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Location')).toBeInTheDocument(); + await expect(canvas.getByRole('link', { name: 'cascadehollow.example' })).toHaveAttribute( + 'href', + 'https://cascadehollow.example', + ); + }, +}; + +export const WithoutCoordinates: Story = { + args: { location: { ...portlandLocation, coordinates: null } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText('123 Hop Street, Portland, OR 97201, United States'), + ).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/LocationCard.tsx b/web/frontend/app/features/breweries/components/detail/LocationCard.tsx new file mode 100644 index 00000000..75165165 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/LocationCard.tsx @@ -0,0 +1,46 @@ +import { lazy, Suspense } from 'react'; +import ClientOnly from '../../../../components/ClientOnly'; +import SidebarCard from '../shared/SidebarCard'; +import { formatBreweryAddress } from '../../utils/format-address'; +import type { BreweryLocation } from '../../breweries.server'; + +const BreweryLocationMap = lazy(() => import('./BreweryLocationMap')); + +interface LocationCardProps { + breweryName: string; + location: BreweryLocation; + website: string; +} + +const MAP_PLACEHOLDER_CLASS = 'skeleton h-45 w-full rounded-field'; + +const LocationCard = ({ breweryName, location, website }: LocationCardProps) => ( + + {location.coordinates && ( + }> + {() => ( + }> + + + )} + + )} +

{formatBreweryAddress(location)}

+

+ + {website} + +

+
+); + +export default LocationCard; diff --git a/web/frontend/app/features/breweries/components/detail/YourRatingCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/YourRatingCard.stories.tsx new file mode 100644 index 00000000..277b49d9 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/YourRatingCard.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import YourRatingCard from './YourRatingCard'; + +const yourRatingCardDescription = `Interactive 1-5 star rating control on the brewery detail page. The hint text switches once a rating is set, and clicking the selected star again clears it.`; + +const meta = { + title: 'Breweries/YourRatingCard', + component: YourRatingCard, + tags: ['autodocs'], + args: { + onChange: fn(), + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: yourRatingCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Unrated: Story = { + args: { value: 0 }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Tap a star to rate this brewery.')).toBeInTheDocument(); + await userEvent.click(canvas.getByRole('radio', { name: 'Rate 4 of 5' })); + await expect(args.onChange).toHaveBeenCalledWith(4); + }, +}; + +export const Rated: Story = { + args: { value: 4 }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText('You rated this 4 of 5. Tap the same star to clear.'), + ).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/YourRatingCard.tsx b/web/frontend/app/features/breweries/components/detail/YourRatingCard.tsx new file mode 100644 index 00000000..1c6510a9 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/YourRatingCard.tsx @@ -0,0 +1,24 @@ +import StarRating from '../shared/StarRating'; + +interface YourRatingCardProps { + value: number; + onChange: (value: number) => void; +} + +const YourRatingCard = ({ value, onChange }: YourRatingCardProps) => ( +
+
+
+

Your rating

+

+ {value + ? `You rated this ${value} of 5. Tap the same star to clear.` + : 'Tap a star to rate this brewery.'} +

+
+ +
+
+); + +export default YourRatingCard; diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx new file mode 100644 index 00000000..539c4354 --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import CommentItem from './CommentItem'; +import type { FillerComment } from '../../../utils/filler-brewery-detail'; + +const commentItemDescription = `A single comment row on the brewery detail page: avatar initials, username/star/timestamp, body, and a per-comment like toggle.`; + +const comment: FillerComment = { + id: '1', + user: 'malt_kettle', + initials: 'MK', + rating: 5, + time: '2 days ago', + text: 'The barrel-aged flanders red is worth the trip alone.', + likes: 12, + liked: false, +}; + +const meta = { + title: 'Breweries/CommentItem', + component: CommentItem, + tags: ['autodocs'], + args: { + onToggleLike: fn(), + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: commentItemDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Unliked: Story = { + args: { comment }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('malt_kettle')).toBeInTheDocument(); + const likeButton = canvas.getByRole('button', { name: /12 likes/i }); + await userEvent.click(likeButton); + await expect(args.onToggleLike).toHaveBeenCalledWith('1'); + }, +}; + +export const Liked: Story = { + args: { comment: { ...comment, liked: true } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx new file mode 100644 index 00000000..370b502d --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentItem.tsx @@ -0,0 +1,46 @@ +import { Heart, HeartSolid } from 'iconoir-react'; +import StarRating from '../../shared/StarRating'; +import type { FillerComment } from '../../../utils/filler-brewery-detail'; + +interface CommentItemProps { + comment: FillerComment; + onToggleLike: (id: string) => void; +} + +const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => { + const likeCount = comment.likes + (comment.liked ? 1 : 0); + + return ( +
+
+
+ {comment.initials} +
+
+
+
+ {comment.user} + + {comment.time} +
+

{comment.text}

+ +
+
+ ); +}; + +export default CommentItem; diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentsCard.stories.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentsCard.stories.tsx new file mode 100644 index 00000000..0e474b7d --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentsCard.stories.tsx @@ -0,0 +1,59 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, fn, userEvent, within } from 'storybook/test'; +import CommentsCard from './CommentsCard'; +import { FILLER_COMMENTS } from '../../../utils/filler-brewery-detail'; + +const commentsCardDescription = `Comments section on the brewery detail page: a post form (logged in) or a sign-in alert (logged out), followed by the comment list. Posting is disabled until the textarea has non-whitespace content.`; + +const meta = { + title: 'Breweries/CommentsCard', + component: CommentsCard, + tags: ['autodocs'], + args: { + comments: FILLER_COMMENTS, + currentUserInitials: 'JD', + onAddComment: fn(), + onToggleCommentLike: fn(), + }, + parameters: { + layout: 'padded', + docs: { + description: { + component: commentsCardDescription, + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const LoggedIn: Story = { + args: { loggedIn: true }, + play: async ({ args, canvasElement }) => { + const canvas = within(canvasElement); + const postButton = canvas.getByRole('button', { name: /post comment/i }); + await expect(postButton).toBeDisabled(); + + await userEvent.type( + canvas.getByPlaceholderText('Share your thoughts on this brewery'), + 'Great taproom!', + ); + await expect(postButton).toBeEnabled(); + await userEvent.click(postButton); + await expect(args.onAddComment).toHaveBeenCalledWith('Great taproom!'); + }, +}; + +export const LoggedOut: Story = { + args: { loggedIn: false }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText('Sign in to like, rate, and comment on breweries.'), + ).toBeInTheDocument(); + await expect( + canvas.queryByPlaceholderText('Share your thoughts on this brewery'), + ).not.toBeInTheDocument(); + }, +}; diff --git a/web/frontend/app/features/breweries/components/detail/comments/CommentsCard.tsx b/web/frontend/app/features/breweries/components/detail/comments/CommentsCard.tsx new file mode 100644 index 00000000..33e327fb --- /dev/null +++ b/web/frontend/app/features/breweries/components/detail/comments/CommentsCard.tsx @@ -0,0 +1,79 @@ +import { useState } from 'react'; +import CommentItem from './CommentItem'; +import type { FillerComment } from '../../../utils/filler-brewery-detail'; + +interface CommentsCardProps { + comments: FillerComment[]; + loggedIn: boolean; + currentUserInitials: string; + onAddComment: (text: string) => void; + onToggleCommentLike: (id: string) => void; +} + +const CommentsCard = ({ + comments, + loggedIn, + currentUserInitials, + onAddComment, + onToggleCommentLike, +}: CommentsCardProps) => { + const [commentText, setCommentText] = useState(''); + + const handleSubmit = () => { + const text = commentText.trim(); + if (!text) return; + onAddComment(text); + setCommentText(''); + }; + + return ( +
+
+

Comments ({comments.length})

+ + {loggedIn ? ( +
+
+
+ {currentUserInitials} +
+
+
+