mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add brewery discovery & detail pages with location-based search (#253)
This commit is contained in:
@@ -60,7 +60,8 @@ Archived/reference areas:
|
|||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
- **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
|
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||||
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||||
- **Infrastructure**: Docker, Docker Compose
|
- **Infrastructure**: Docker, Docker Compose
|
||||||
|
|||||||
@@ -309,7 +309,12 @@ it, without an ORM's change-tracking or mapping magic
|
|||||||
through Dapper, with no manual `IDbCommand` parameter binding. The one
|
through Dapper, with no manual `IDbCommand` parameter binding. The one
|
||||||
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
|
wrinkle is the `GEOGRAPHY` column on `BreweryPostLocation`, which Dapper
|
||||||
can't deserialize as a UDT: reads select `CONVERT(varbinary(max),
|
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
|
- Referential checks that a stored procedure used to perform (e.g. "does this
|
||||||
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
`CityId` exist?") are now explicit `SELECT 1 ...` existence checks in the
|
||||||
repository method, run inside the same transaction as the write
|
repository method, run inside the same transaction as the write
|
||||||
@@ -335,9 +340,22 @@ enabled.
|
|||||||
```text
|
```text
|
||||||
web/frontend/
|
web/frontend/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
|
│ ├── components/ Shared UI: Navbar, FormField, SubmitButton, ToastProvider,
|
||||||
│ ├── lib/ Auth helpers, schemas, and theme metadata
|
│ │ RouteErrorState, ClientOnly (defers client-only children,
|
||||||
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
|
│ │ 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
|
│ ├── root.tsx App shell and global providers
|
||||||
│ └── app.css Theme tokens and global styling
|
│ └── app.css Theme tokens and global styling
|
||||||
├── .storybook/ Storybook config and preview setup
|
├── .storybook/ Storybook config and preview setup
|
||||||
@@ -348,9 +366,13 @@ web/frontend/
|
|||||||
|
|
||||||
### Frontend responsibilities
|
### 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
|
- 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
|
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||||
- Supply Storybook documentation and browser-based component verification
|
- Supply Storybook documentation and browser-based component verification
|
||||||
|
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
|||||||
**Features.Breweries.Tests**:
|
**Features.Breweries.Tests**:
|
||||||
|
|
||||||
- Brewery create/update/delete commands and get-by-id/get-all queries
|
- 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**:
|
**Features.UserManagement.Tests**:
|
||||||
|
|
||||||
|
|||||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "the-biergarten-app",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -41,14 +41,10 @@ CREATE
|
|||||||
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||||
ON BreweryPostLocation(CityID);
|
ON BreweryPostLocation(CityID);
|
||||||
|
|
||||||
-- To assess when the time comes:
|
CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||||
|
ON BreweryPostLocation(Coordinates)
|
||||||
-- 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.
|
USING GEOGRAPHY_GRID
|
||||||
|
WITH (
|
||||||
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
||||||
-- ON BreweryPostLocation(Coordinates)
|
CELLS_PER_OBJECT = 16
|
||||||
-- USING GEOGRAPHY_GRID
|
);
|
||||||
-- WITH (
|
|
||||||
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
|
||||||
-- CELLS_PER_OBJECT = 16
|
|
||||||
-- );
|
|
||||||
|
|||||||
@@ -21,4 +21,7 @@ public sealed class BreweryPost
|
|||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public BreweryPostLocation? Location { get; set; }
|
public BreweryPostLocation? Location { get; set; }
|
||||||
public List<BeerPost> BeerPosts { get; set; } = [];
|
public List<BeerPost> BeerPosts { get; set; } = [];
|
||||||
|
|
||||||
|
// Query-computed
|
||||||
|
public DistanceInformation? Distance { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ namespace Domain.Entities;
|
|||||||
|
|
||||||
public sealed record CoordinateData(double Latitude, double Longitude);
|
public sealed record CoordinateData(double Latitude, double Longitude);
|
||||||
|
|
||||||
|
/// <summary>The distance, as computed by the database, from a query's origin point to a brewery.</summary>
|
||||||
|
public sealed record DistanceInformation(CoordinateData From, double DistanceMetres);
|
||||||
|
|
||||||
public sealed class BreweryPostLocation
|
public sealed class BreweryPostLocation
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
|
public GetAllBreweryLocationsHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetAllBreweryLocationsHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_ReturnsEmpty_WhenNoBreweriesHaveALocation()
|
||||||
|
{
|
||||||
|
_repoMock.Setup(r => r.GetAllLocations()).ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
|
IEnumerable<SimplifiedBreweryDto> 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<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||||
|
new GetAllBreweryLocationsQuery(),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result
|
||||||
|
.Select(b => b.BreweryPostId)
|
||||||
|
.Should()
|
||||||
|
.BeEquivalentTo(breweries.Select(b => b.BreweryPostId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IBreweryRepository> _repoMock = new();
|
||||||
|
|
||||||
|
public GetBreweryLocationsWithinRangeHandlerTests()
|
||||||
|
{
|
||||||
|
_handler = new GetBreweryLocationsWithinRangeHandler(_repoMock.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PassesCoordinatesAndRange_ToRepository()
|
||||||
|
{
|
||||||
|
_repoMock
|
||||||
|
.Setup(r =>
|
||||||
|
r.GetAllLocationsWithinRange(
|
||||||
|
It.Is<CoordinateData>(c => c.Latitude == 43.6532 && c.Longitude == -79.3832),
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(Array.Empty<BreweryPost>());
|
||||||
|
|
||||||
|
IEnumerable<SimplifiedBreweryDto> result = await _handler.Handle(
|
||||||
|
new GetBreweryLocationsWithinRangeQuery(43.6532, -79.3832, 1000),
|
||||||
|
CancellationToken.None
|
||||||
|
);
|
||||||
|
|
||||||
|
result.Should().BeEmpty();
|
||||||
|
_repoMock.Verify(
|
||||||
|
r =>
|
||||||
|
r.GetAllLocationsWithinRange(
|
||||||
|
It.Is<CoordinateData>(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<CoordinateData>(), It.IsAny<double>()))
|
||||||
|
.ReturnsAsync(breweries);
|
||||||
|
|
||||||
|
IEnumerable<SimplifiedBreweryDto> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ using Features.Breweries.Commands.DeleteBrewery;
|
|||||||
using Features.Breweries.Commands.UpdateBrewery;
|
using Features.Breweries.Commands.UpdateBrewery;
|
||||||
using Features.Breweries.Dtos;
|
using Features.Breweries.Dtos;
|
||||||
using Features.Breweries.Queries.GetAllBreweries;
|
using Features.Breweries.Queries.GetAllBreweries;
|
||||||
|
using Features.Breweries.Queries.GetAllBreweryLocations;
|
||||||
using Features.Breweries.Queries.GetBreweryById;
|
using Features.Breweries.Queries.GetBreweryById;
|
||||||
|
using Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -64,6 +66,49 @@ public class BreweryController(IMediator mediator) : ControllerBase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Retrieves all brewery posts that have a set location.</summary>
|
||||||
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("locations")]
|
||||||
|
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetAllLocations()
|
||||||
|
{
|
||||||
|
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
|
||||||
|
new GetAllBreweryLocationsQuery()
|
||||||
|
);
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
|
||||||
|
{
|
||||||
|
Message = "Brewery locations retrieved successfully.",
|
||||||
|
Payload = breweries,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Retrieves brewery posts with a set location within range of the given coordinates, nearest first.</summary>
|
||||||
|
/// <param name="latitude">The origin latitude, in decimal degrees.</param>
|
||||||
|
/// <param name="longitude">The origin longitude, in decimal degrees.</param>
|
||||||
|
/// <param name="rangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
|
||||||
|
/// <remarks>Allows anonymous access.</remarks>
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("locations/nearby")]
|
||||||
|
public async Task<ActionResult<ResponseBody<IEnumerable<SimplifiedBreweryDto>>>> GetLocationsWithinRange(
|
||||||
|
[FromQuery] double latitude,
|
||||||
|
[FromQuery] double longitude,
|
||||||
|
[FromQuery] double rangeInMetres
|
||||||
|
)
|
||||||
|
{
|
||||||
|
IEnumerable<SimplifiedBreweryDto> breweries = await mediator.Send(
|
||||||
|
new GetBreweryLocationsWithinRangeQuery(latitude, longitude, rangeInMetres)
|
||||||
|
);
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<IEnumerable<SimplifiedBreweryDto>>
|
||||||
|
{
|
||||||
|
Message = "Brewery locations retrieved successfully.",
|
||||||
|
Payload = breweries,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Creates a new brewery post.</summary>
|
/// <summary>Creates a new brewery post.</summary>
|
||||||
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
/// <returns><c>201 Created</c> with the newly created brewery.</returns>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|||||||
@@ -32,3 +32,13 @@ public record BreweryDto(
|
|||||||
byte[]? RowVersion,
|
byte[]? RowVersion,
|
||||||
BreweryLocationDto? Location
|
BreweryLocationDto? Location
|
||||||
);
|
);
|
||||||
|
/// <param name="DistanceMetres">
|
||||||
|
/// The distance, in metres, from a query's origin point, or <see langword="null"/> for queries with no
|
||||||
|
/// origin point (e.g. <c>GET /api/brewery/locations</c>).
|
||||||
|
/// </param>
|
||||||
|
public record SimplifiedBreweryDto(
|
||||||
|
Guid BreweryPostId,
|
||||||
|
string BreweryName,
|
||||||
|
BreweryLocationDto? Location,
|
||||||
|
double? DistanceMetres
|
||||||
|
);
|
||||||
|
|||||||
@@ -40,4 +40,36 @@ public static class BreweryDtoMapper
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a <see cref="BreweryPost" /> to its simplified DTO representation, used for
|
||||||
|
/// location-based queries that only need identifying and location details.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetAllBreweryLocations;
|
||||||
|
|
||||||
|
/// <summary>Handles <see cref="GetAllBreweryLocationsQuery" /> by retrieving all located brewery posts.</summary>
|
||||||
|
public class GetAllBreweryLocationsHandler(IBreweryRepository repository)
|
||||||
|
: IRequestHandler<GetAllBreweryLocationsQuery, IEnumerable<SimplifiedBreweryDto>>
|
||||||
|
{
|
||||||
|
/// <summary>Retrieves every brewery post that has a set location.</summary>
|
||||||
|
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
|
||||||
|
GetAllBreweryLocationsQuery request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
IEnumerable<BreweryPost> breweries = await repository.GetAllLocations();
|
||||||
|
return breweries.Select(b => b.ToSimplifiedDto());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetAllBreweryLocations;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all brewery posts that have a set location. Bound directly from
|
||||||
|
/// <c>GET /api/brewery/locations</c>.
|
||||||
|
/// </summary>
|
||||||
|
public record GetAllBreweryLocationsQuery : IRequest<IEnumerable<SimplifiedBreweryDto>>;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Domain.Entities;
|
||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using Features.Breweries.Repository;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles <see cref="GetBreweryLocationsWithinRangeQuery" /> by retrieving nearby located brewery posts.
|
||||||
|
/// </summary>
|
||||||
|
public class GetBreweryLocationsWithinRangeHandler(IBreweryRepository repository)
|
||||||
|
: IRequestHandler<GetBreweryLocationsWithinRangeQuery, IEnumerable<SimplifiedBreweryDto>>
|
||||||
|
{
|
||||||
|
/// <summary>Retrieves brewery posts within range of the requested coordinates, nearest first.</summary>
|
||||||
|
public async Task<IEnumerable<SimplifiedBreweryDto>> Handle(
|
||||||
|
GetBreweryLocationsWithinRangeQuery request,
|
||||||
|
CancellationToken cancellationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
IEnumerable<BreweryPost> breweries = await repository.GetAllLocationsWithinRange(
|
||||||
|
new CoordinateData(request.Latitude, request.Longitude),
|
||||||
|
request.RangeInMetres
|
||||||
|
);
|
||||||
|
return breweries.Select(b => b.ToSimplifiedDto());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Features.Breweries.Dtos;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves brewery posts with a set location within <paramref name="RangeInMetres" /> of the given
|
||||||
|
/// coordinates, nearest first. Bound directly from the query string of
|
||||||
|
/// <c>GET /api/brewery/locations/nearby</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Latitude">The origin latitude, in decimal degrees.</param>
|
||||||
|
/// <param name="Longitude">The origin longitude, in decimal degrees.</param>
|
||||||
|
/// <param name="RangeInMetres">The maximum distance, in metres, from the origin coordinates.</param>
|
||||||
|
public record GetBreweryLocationsWithinRangeQuery(double Latitude, double Longitude, double RangeInMetres)
|
||||||
|
: IRequest<IEnumerable<SimplifiedBreweryDto>>;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace Features.Breweries.Queries.GetBreweryLocationsWithinRange;
|
||||||
|
|
||||||
|
/// <summary>Validates <see cref="GetBreweryLocationsWithinRangeQuery" />.</summary>
|
||||||
|
public class GetBreweryLocationsWithinRangeValidator
|
||||||
|
: AbstractValidator<GetBreweryLocationsWithinRangeQuery>
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,102 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IEnumerable<BreweryPost>> 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<IEnumerable<BreweryPost>> 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
public async Task<BreweryPost> UpdateAsync(BreweryPost brewery)
|
||||||
{
|
{
|
||||||
@@ -490,4 +586,29 @@ public class BreweryRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
post.Location = location;
|
post.Location = location;
|
||||||
return post;
|
return post;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Dapper multi-mapping row for the <c>DistanceMetres</c> column computed by a range query.</summary>
|
||||||
|
private sealed class DistanceRow
|
||||||
|
{
|
||||||
|
public double DistanceMetres { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Composes a joined row via <see cref="MapBreweryRow" />, then attaches the DB-computed distance
|
||||||
|
/// from <paramref name="origin" /> as the result's <see cref="BreweryPost.Distance" />.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,6 +21,20 @@ public interface IBreweryRepository
|
|||||||
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param>
|
/// <param name="offset">Number of rows to skip. Treated as zero if <see langword="null" />.</param>
|
||||||
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
Task<IEnumerable<BreweryPost>> GetAllAsync(int? limit, int? offset);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves brewery posts with a set location within <paramref name="rangeInMetres" /> of
|
||||||
|
/// <paramref name="coords" />, nearest first. Posts without coordinates are excluded.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="coords">The origin point to measure distance from.</param>
|
||||||
|
/// <param name="rangeInMetres">The maximum distance, in metres, from <paramref name="coords" />.</param>
|
||||||
|
Task<IEnumerable<BreweryPost>> GetAllLocationsWithinRange(CoordinateData coords, double rangeInMetres);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all brewery posts that have a set location, ordered by <c>BreweryPostId</c>. Posts
|
||||||
|
/// without coordinates are excluded.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<BreweryPost>> GetAllLocations();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
|
/// Updates a brewery post's name and description, and upserts or clears its location, enforcing
|
||||||
/// optimistic concurrency via <paramref name="brewery" />'s <c>RowVersion</c>: the update is rejected
|
/// optimistic concurrency via <paramref name="brewery" />'s <c>RowVersion</c>: the update is rejected
|
||||||
|
|||||||
@@ -1,19 +1,7 @@
|
|||||||
import type { StorybookConfig } from '@storybook/react-vite';
|
import type { StorybookConfig } from '@storybook/react-vite';
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: [
|
stories: ['../stories/Configure.mdx', '../app/**/*.stories.tsx'],
|
||||||
'../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',
|
|
||||||
],
|
|
||||||
addons: [
|
addons: [
|
||||||
'@chromatic-com/storybook',
|
'@chromatic-com/storybook',
|
||||||
'@storybook/addon-vitest',
|
'@storybook/addon-vitest',
|
||||||
|
|||||||
21
web/frontend/app/components/ClientOnly.tsx
Normal file
21
web/frontend/app/components/ClientOnly.tsx
Normal file
@@ -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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { expect, within } from 'storybook/test';
|
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.`;
|
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.`;
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { expect, within } from 'storybook/test';
|
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.`;
|
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.`;
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { expect, userEvent, within } from 'storybook/test';
|
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.`;
|
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.`;
|
||||||
|
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { expect, screen, userEvent, within } from 'storybook/test';
|
import { expect, screen, userEvent, within } from 'storybook/test';
|
||||||
import ToastProvider from '../app/components/ui/toast/ToastProvider';
|
import ToastProvider from './ToastProvider';
|
||||||
import {
|
import { dismissToasts, showErrorToast, showInfoToast, showSuccessToast } from './toast';
|
||||||
dismissToasts,
|
|
||||||
showErrorToast,
|
|
||||||
showInfoToast,
|
|
||||||
showSuccessToast,
|
|
||||||
} from '../app/components/ui/toast/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.`;
|
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.`;
|
||||||
|
|
||||||
@@ -2,9 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
|
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
|
||||||
import { expect, userEvent, within } from 'storybook/test';
|
import { expect, userEvent, within } from 'storybook/test';
|
||||||
import PasswordSection from '../app/features/account/components/PasswordSection';
|
import PasswordSection from './PasswordSection';
|
||||||
import { updatePasswordSchema } from '../app/features/account/schemas';
|
import { updatePasswordSchema } from '../schemas';
|
||||||
import type { ActionResult } from '../app/features/account/types';
|
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.`;
|
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 (
|
return (
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<PasswordSection open={open} onToggle={() => setOpen((value) => !value)} result={result} />
|
<PasswordSection
|
||||||
|
open={open}
|
||||||
|
onToggle={() => setOpen((value) => !value)}
|
||||||
|
result={result}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import { Lock } from 'iconoir-react';
|
import { Lock } from 'iconoir-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { expect, userEvent, within } from 'storybook/test';
|
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.`;
|
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.`;
|
||||||
|
|
||||||
@@ -2,9 +2,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
|
import { createMemoryRouter, redirect, RouterProvider, useActionData } from 'react-router';
|
||||||
import { expect, userEvent, within } from 'storybook/test';
|
import { expect, userEvent, within } from 'storybook/test';
|
||||||
import UsernameSection from '../app/features/account/components/UsernameSection';
|
import UsernameSection from './UsernameSection';
|
||||||
import { updateUsernameSchema } from '../app/features/account/schemas';
|
import { updateUsernameSchema } from '../schemas';
|
||||||
import type { ActionResult } from '../app/features/account/types';
|
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.`;
|
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.`;
|
||||||
|
|
||||||
@@ -2,7 +2,10 @@ import { useEffect } from 'react';
|
|||||||
import { showErrorToast } from '../../../components/ui/toast/toast';
|
import { showErrorToast } from '../../../components/ui/toast/toast';
|
||||||
import type { ActionResult } from '../types';
|
import type { ActionResult } from '../types';
|
||||||
|
|
||||||
export const useSectionError = (result: ActionResult | undefined, intent: ActionResult['intent']) => {
|
export const useSectionError = (
|
||||||
|
result: ActionResult | undefined,
|
||||||
|
intent: ActionResult['intent'],
|
||||||
|
) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!result || result.intent !== intent) return;
|
if (!result || result.intent !== intent) return;
|
||||||
showErrorToast(result.error);
|
showErrorToast(result.error);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { expect, userEvent, within } from 'storybook/test';
|
import { expect, userEvent, within } from 'storybook/test';
|
||||||
import LoginForm from '../app/features/auth/components/LoginForm';
|
import LoginForm from './LoginForm';
|
||||||
import { loginSchema, type LoginSchema } from '../app/features/auth/schemas';
|
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.`;
|
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.`;
|
||||||
|
|
||||||
@@ -3,8 +3,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
import { expect, fireEvent, userEvent, within } from 'storybook/test';
|
||||||
import RegisterForm from '../app/features/auth/components/RegisterForm';
|
import RegisterForm from './RegisterForm';
|
||||||
import { registerSchema, type RegisterSchema } from '../app/features/auth/schemas';
|
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.`;
|
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.`;
|
||||||
|
|
||||||
101
web/frontend/app/features/breweries/BREWERY_HANDOFF.md
Normal file
101
web/frontend/app/features/breweries/BREWERY_HANDOFF.md
Normal file
@@ -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`).
|
||||||
@@ -7,13 +7,24 @@ interface ApiResponse<T> {
|
|||||||
payload: T;
|
payload: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BreweryCoordinates {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BreweryLocation {
|
export interface BreweryLocation {
|
||||||
breweryPostLocationId: string;
|
breweryPostLocationId: string;
|
||||||
breweryPostId: string;
|
breweryPostId: string;
|
||||||
cityId: string;
|
cityId: string;
|
||||||
|
cityName: string;
|
||||||
|
stateProvinceName: string;
|
||||||
|
stateProvinceCode: string;
|
||||||
|
countryName: string;
|
||||||
|
countryCode: string;
|
||||||
addressLine1: string;
|
addressLine1: string;
|
||||||
addressLine2: string | null;
|
addressLine2: string | null;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
|
coordinates: BreweryCoordinates | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Brewery {
|
export interface Brewery {
|
||||||
@@ -26,6 +37,13 @@ export interface Brewery {
|
|||||||
location: BreweryLocation | null;
|
location: BreweryLocation | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SimplifiedBrewery {
|
||||||
|
breweryPostId: string;
|
||||||
|
breweryName: string;
|
||||||
|
location: BreweryLocation | null;
|
||||||
|
distanceMetres: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
const fetchApi = async (path: string): Promise<Response> => {
|
const fetchApi = async (path: string): Promise<Response> => {
|
||||||
try {
|
try {
|
||||||
return await fetch(`${API_BASE_URL}${path}`);
|
return await fetch(`${API_BASE_URL}${path}`);
|
||||||
@@ -55,6 +73,44 @@ export const getBreweries = async (limit?: number, offset?: number): Promise<Bre
|
|||||||
return body.payload;
|
return body.payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBreweryLocations = async (): Promise<SimplifiedBrewery[]> => {
|
||||||
|
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<SimplifiedBrewery[]> = await res.json();
|
||||||
|
return body.payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBreweryLocationsNearby = async (
|
||||||
|
latitude: number,
|
||||||
|
longitude: number,
|
||||||
|
rangeInMetres: number,
|
||||||
|
): Promise<SimplifiedBrewery[]> => {
|
||||||
|
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<SimplifiedBrewery[]> = await res.json();
|
||||||
|
return body.payload;
|
||||||
|
};
|
||||||
|
|
||||||
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
|
export const getBreweryById = async (id: string): Promise<Brewery | null> => {
|
||||||
const res = await fetchApi(`/api/brewery/${encodeURIComponent(id)}`);
|
const res = await fetchApi(`/api/brewery/${encodeURIComponent(id)}`);
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Link } from 'react-router';
|
|
||||||
import type { Brewery } from '../breweries.server';
|
|
||||||
|
|
||||||
interface BreweryCardProps {
|
|
||||||
brewery: Brewery;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
|
||||||
<Link
|
|
||||||
to={`/breweries/${brewery.breweryPostId}`}
|
|
||||||
className="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div className="card-body">
|
|
||||||
<h2 className="card-title">{brewery.breweryName}</h2>
|
|
||||||
<p className="text-base-content/70 line-clamp-3">{brewery.description}</p>
|
|
||||||
{brewery.location && (
|
|
||||||
<p className="text-sm text-base-content/50 mt-2">
|
|
||||||
{brewery.location.addressLine1}
|
|
||||||
{brewery.location.addressLine2 ? `, ${brewery.location.addressLine2}` : ''}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default BreweryCard;
|
|
||||||
@@ -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<typeof BeerListCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) => (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body gap-3 p-6">
|
||||||
|
<div className="flex items-baseline justify-between gap-4">
|
||||||
|
<h3 className="text-xl m-0">Beers ({beers.length})</h3>
|
||||||
|
<Link to="/beers" className="link link-primary text-sm">
|
||||||
|
View all beers
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{beers.map((beer) => (
|
||||||
|
<div
|
||||||
|
key={beer.id}
|
||||||
|
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-3 py-2.5 border-t border-base-content/10 first:border-t-0"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5 min-w-0">
|
||||||
|
<span className="font-bold text-sm">{beer.name}</span>
|
||||||
|
<span className="text-xs text-base-content/60">{beer.description}</span>
|
||||||
|
</div>
|
||||||
|
<span className="badge badge-ghost bg-base-300 text-xs font-semibold whitespace-nowrap">
|
||||||
|
{beer.style}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-base-content/60 tabular-nums whitespace-nowrap">
|
||||||
|
{beer.abv.toFixed(1)}% ABV
|
||||||
|
</span>
|
||||||
|
<StarRating value={beer.rating} size="xs" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BeerListCard;
|
||||||
@@ -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<typeof BeerStyleBreakdownCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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<string, number>();
|
||||||
|
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 (
|
||||||
|
<SidebarCard title="Beer styles">
|
||||||
|
{styles.map(([style, count]) => (
|
||||||
|
<div key={style} className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-semibold">{style}</span>
|
||||||
|
<span className="text-xs text-base-content/60 tabular-nums">{count}</span>
|
||||||
|
</div>
|
||||||
|
<progress
|
||||||
|
className="progress progress-primary h-1.5"
|
||||||
|
value={count}
|
||||||
|
max={maxCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SidebarCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BeerStyleBreakdownCard;
|
||||||
@@ -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<typeof BreweryDetailsCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 }) => (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="text-base-content/60">{label}</span>
|
||||||
|
<span className="font-semibold">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const BreweryDetailsCard = ({ foundedYear, type, beerCount }: BreweryDetailsCardProps) => (
|
||||||
|
<SidebarCard title="Details">
|
||||||
|
<DetailRow label="Founded" value={foundedYear} />
|
||||||
|
<DetailRow label="Type" value={type} />
|
||||||
|
<DetailRow label="Beers listed" value={beerCount} />
|
||||||
|
</SidebarCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BreweryDetailsCard;
|
||||||
@@ -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<typeof BreweryHeaderCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) => (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body gap-4 p-7">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-base-content/50">
|
||||||
|
Brewery
|
||||||
|
</span>
|
||||||
|
<span className="badge bg-[var(--color-highlight)] text-[var(--color-highlight-content)]">
|
||||||
|
Est. {foundedYear}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl leading-tight m-0">{brewery.breweryName}</h1>
|
||||||
|
{brewery.location && (
|
||||||
|
<p className="text-sm text-base-content/60 flex items-center gap-1 m-0">
|
||||||
|
<MapPin className="size-4" aria-hidden="true" />
|
||||||
|
{formatBreweryAddress(brewery.location)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleLike}
|
||||||
|
className={`btn btn-sm ${liked ? 'btn-primary' : 'btn-outline'}`}
|
||||||
|
>
|
||||||
|
{liked ? (
|
||||||
|
<HeartSolid className="size-4" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Heart className="size-4" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{liked ? 'Liked' : 'Like'} · {likeCount}
|
||||||
|
</button>
|
||||||
|
<div className="flex items-baseline gap-1.5">
|
||||||
|
<StarRating value={avgRating} size="sm" />
|
||||||
|
<span className="font-bold">{avgRating.toFixed(1)}</span>
|
||||||
|
<span className="text-sm text-base-content/60">({ratingsCount} ratings)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-base-content/70 leading-relaxed text-pretty m-0">
|
||||||
|
{brewery.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BreweryHeaderCard;
|
||||||
@@ -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<typeof BreweryLocationMap>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
latitude: 45.5152,
|
||||||
|
longitude: -122.6784,
|
||||||
|
label: 'Cascade Hollow Brewing — 123 Hop Street',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<MapContainer
|
||||||
|
center={position}
|
||||||
|
zoom={ZOOM}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
className="h-45 w-full rounded-field relative z-0"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
noWrap
|
||||||
|
/>
|
||||||
|
<Marker position={position}>
|
||||||
|
<Popup>{label}</Popup>
|
||||||
|
</Marker>
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BreweryLocationMap;
|
||||||
@@ -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<typeof CommunityStatsCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 }) => (
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="text-base-content/60">{label}</span>
|
||||||
|
<span className="font-semibold tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CommunityStatsCard = ({ likeCount, ratingsCount, commentCount }: CommunityStatsCardProps) => (
|
||||||
|
<SidebarCard title="Community">
|
||||||
|
<StatRow label="Likes" value={likeCount} />
|
||||||
|
<StatRow label="Ratings" value={ratingsCount} />
|
||||||
|
<StatRow label="Comments" value={commentCount} />
|
||||||
|
</SidebarCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CommunityStatsCard;
|
||||||
@@ -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<typeof LocationCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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) => (
|
||||||
|
<SidebarCard title="Location">
|
||||||
|
{location.coordinates && (
|
||||||
|
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
|
||||||
|
{() => (
|
||||||
|
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
|
||||||
|
<BreweryLocationMap
|
||||||
|
latitude={location.coordinates!.latitude}
|
||||||
|
longitude={location.coordinates!.longitude}
|
||||||
|
label={`${breweryName} — ${location.addressLine1}`}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
)}
|
||||||
|
<p className="text-sm leading-relaxed m-0">{formatBreweryAddress(location)}</p>
|
||||||
|
<p className="text-sm m-0">
|
||||||
|
<a
|
||||||
|
href={`https://${website}`}
|
||||||
|
className="link link-primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{website}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</SidebarCard>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default LocationCard;
|
||||||
@@ -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<typeof YourRatingCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import StarRating from '../shared/StarRating';
|
||||||
|
|
||||||
|
interface YourRatingCardProps {
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const YourRatingCard = ({ value, onChange }: YourRatingCardProps) => (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body flex-row flex-wrap items-center justify-between gap-4 p-6">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<h3 className="text-xl m-0">Your rating</h3>
|
||||||
|
<p className="text-sm text-base-content/60 m-0">
|
||||||
|
{value
|
||||||
|
? `You rated this ${value} of 5. Tap the same star to clear.`
|
||||||
|
: 'Tap a star to rate this brewery.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StarRating value={value} onChange={onChange} size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default YourRatingCard;
|
||||||
@@ -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<typeof CommentItem>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="flex gap-3 items-start border-t border-base-content/10 pt-4">
|
||||||
|
<div className="avatar avatar-placeholder shrink-0">
|
||||||
|
<div className="bg-secondary text-secondary-content w-9 rounded-full">
|
||||||
|
<span className="text-sm font-bold">{comment.initials}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
||||||
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
|
<span className="font-bold text-sm">{comment.user}</span>
|
||||||
|
<StarRating value={comment.rating} size="xs" />
|
||||||
|
<span className="text-xs text-base-content/60">{comment.time}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed m-0">{comment.text}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onToggleLike(comment.id)}
|
||||||
|
className={`self-start inline-flex items-center gap-1 text-xs font-semibold ${
|
||||||
|
comment.liked ? 'text-primary' : 'text-base-content/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{comment.liked ? (
|
||||||
|
<HeartSolid className="size-3.5" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<Heart className="size-3.5" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{likeCount} {likeCount === 1 ? 'like' : 'likes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentItem;
|
||||||
@@ -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<typeof CommentsCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body gap-5 p-6">
|
||||||
|
<h3 className="text-xl m-0">Comments ({comments.length})</h3>
|
||||||
|
|
||||||
|
{loggedIn ? (
|
||||||
|
<div className="flex gap-3 items-start">
|
||||||
|
<div className="avatar avatar-placeholder shrink-0">
|
||||||
|
<div className="bg-secondary text-secondary-content w-9 rounded-full">
|
||||||
|
<span className="text-sm font-bold">{currentUserInitials}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder="Share your thoughts on this brewery"
|
||||||
|
value={commentText}
|
||||||
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
|
className="textarea w-full"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
className="btn btn-sm btn-primary self-end"
|
||||||
|
>
|
||||||
|
Post comment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div role="alert" className="alert alert-info alert-soft">
|
||||||
|
Sign in to like, rate, and comment on breweries.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<CommentItem
|
||||||
|
key={comment.id}
|
||||||
|
comment={comment}
|
||||||
|
onToggleLike={onToggleCommentLike}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CommentsCard;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
import BreweryCard from './BreweryCard';
|
||||||
|
import type { Brewery, BreweryLocation } from '../../breweries.server';
|
||||||
|
import { formatBreweryAddress } from '../../utils/format-address';
|
||||||
|
|
||||||
|
const breweryCardDescription = `Link-card for a brewery shown in listing grids. These stories cover a brewery with a full mapped location, one whose location has no coordinates (no "View on map" link), and one with no location at all.`;
|
||||||
|
|
||||||
|
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 breweryWithLocation: 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 breweryWithoutCoordinates: Brewery = {
|
||||||
|
...breweryWithLocation,
|
||||||
|
breweryPostId: 'brewery-2',
|
||||||
|
breweryName: 'Foggy Bottom Brewhouse',
|
||||||
|
location: { ...portlandLocation, coordinates: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const breweryWithoutLocation: Brewery = {
|
||||||
|
...breweryWithLocation,
|
||||||
|
breweryPostId: 'brewery-3',
|
||||||
|
breweryName: 'Unmapped Ales',
|
||||||
|
location: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/BreweryCard',
|
||||||
|
component: BreweryCard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: breweryCardDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof BreweryCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const WithLocation: Story = {
|
||||||
|
args: { brewery: breweryWithLocation },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(
|
||||||
|
canvas.getByRole('heading', { name: 'Cascade Hollow Brewing' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('Portland, OR')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText(formatBreweryAddress(portlandLocation))).toBeInTheDocument();
|
||||||
|
|
||||||
|
const cardLink = canvas.getByRole('link', { name: /cascade hollow brewing/i });
|
||||||
|
await expect(cardLink).toHaveAttribute('href', '/breweries/brewery-1');
|
||||||
|
|
||||||
|
const mapLink = canvas.getByRole('link', { name: 'View on map →' });
|
||||||
|
await expect(mapLink).toHaveAttribute('href', expect.stringContaining('mlat=45.5152'));
|
||||||
|
await expect(mapLink).toHaveAttribute('target', '_blank');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutCoordinates: Story = {
|
||||||
|
args: { brewery: breweryWithoutCoordinates },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByText('Portland, OR')).toBeInTheDocument();
|
||||||
|
await expect(canvas.queryByRole('link', { name: /view on map/i })).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutLocation: Story = {
|
||||||
|
args: { brewery: breweryWithoutLocation },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByRole('heading', { name: 'Unmapped Ales' })).toBeInTheDocument();
|
||||||
|
await expect(canvas.queryByText('Portland, OR')).not.toBeInTheDocument();
|
||||||
|
await expect(canvas.queryByRole('link', { name: /view on map/i })).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import type { Brewery } from '../../breweries.server';
|
||||||
|
import { formatBreweryAddress } from '../../utils/format-address';
|
||||||
|
|
||||||
|
interface BreweryCardProps {
|
||||||
|
brewery: Brewery;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BreweryCard = ({ brewery }: BreweryCardProps) => (
|
||||||
|
<Link
|
||||||
|
to={`/breweries/${brewery.breweryPostId}`}
|
||||||
|
className="card bg-base-100 shadow hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title">{brewery.breweryName}</h2>
|
||||||
|
{brewery.location && (
|
||||||
|
<p className="text-sm font-medium text-base-content/70">
|
||||||
|
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{brewery.description && (
|
||||||
|
<p className="text-sm leading-5 text-base-content/70 line-clamp-2 max-h-10 overflow-hidden">
|
||||||
|
{brewery.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{brewery.location && (
|
||||||
|
<p className="text-sm text-base-content/50 mt-2">
|
||||||
|
{formatBreweryAddress(brewery.location)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{brewery.location?.coordinates && (
|
||||||
|
<a
|
||||||
|
href={`https://www.openstreetmap.org/?mlat=${brewery.location.coordinates.latitude}&mlon=${brewery.location.coordinates.longitude}#map=16/${brewery.location.coordinates.latitude}/${brewery.location.coordinates.longitude}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="link link-primary text-sm mt-2 self-start"
|
||||||
|
>
|
||||||
|
View on map →
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BreweryCard;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import BreweryMap, { type BreweryMapPin } from './BreweryMap';
|
||||||
|
import type { BreweryLocation } from '../../breweries.server';
|
||||||
|
|
||||||
|
const breweryMapDescription = `Leaflet map plotting brewery locations, with an optional current-location marker. Smoke-render only - this hits the real OpenStreetMap tile server in the browser test run, and its \`navigator.geolocation\` call is tolerated whether or not it succeeds.`;
|
||||||
|
|
||||||
|
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 pins: BreweryMapPin[] = [
|
||||||
|
{
|
||||||
|
id: 'brewery-1',
|
||||||
|
name: 'Cascade Hollow Brewing',
|
||||||
|
latitude: 45.5152,
|
||||||
|
longitude: -122.6784,
|
||||||
|
location: portlandLocation,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brewery-2',
|
||||||
|
name: 'Foggy Bottom Brewhouse',
|
||||||
|
latitude: 44.06,
|
||||||
|
longitude: -121.31,
|
||||||
|
location: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/BreweryMap',
|
||||||
|
component: BreweryMap,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: breweryMapDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof BreweryMap>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { breweries: pins },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoBreweries: Story = {
|
||||||
|
args: { breweries: [] },
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
|
import L, { type LatLngExpression, 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';
|
||||||
|
import useMediaQuery from '../../../../hooks/useMediaQuery';
|
||||||
|
import type { BreweryLocation } from '../../breweries.server';
|
||||||
|
import { formatBreweryAddress } from '../../utils/format-address';
|
||||||
|
|
||||||
|
delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl });
|
||||||
|
|
||||||
|
export interface BreweryMapPin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
location: BreweryLocation | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BreweryMapProps {
|
||||||
|
breweries: BreweryMapPin[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_CENTER: LatLngExpression = [20, 0];
|
||||||
|
const WORLD_BOUNDS: LatLngTuple[] = [
|
||||||
|
[-90, -180],
|
||||||
|
[90, 180],
|
||||||
|
];
|
||||||
|
|
||||||
|
const CURRENT_LOCATION_ICON = L.divIcon({
|
||||||
|
className: 'brewery-map-current-location',
|
||||||
|
html: '<span class="block h-3.5 w-3.5 rounded-full bg-primary border-2 border-base-100 shadow-md"></span>',
|
||||||
|
iconSize: [14, 14],
|
||||||
|
iconAnchor: [7, 7],
|
||||||
|
});
|
||||||
|
|
||||||
|
const useCurrentLocation = () => {
|
||||||
|
const [position, setPosition] = useState<LatLngTuple | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!navigator.geolocation) return;
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => setPosition([pos.coords.latitude, pos.coords.longitude]),
|
||||||
|
() => setPosition(null),
|
||||||
|
{ enableHighAccuracy: true },
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return position;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CurrentLocationMarker = ({ position }: { position: LatLngTuple }) => (
|
||||||
|
<Marker position={position} icon={CURRENT_LOCATION_ICON}>
|
||||||
|
<Popup>Your location</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CURRENT_LOCATION_ZOOM = 6;
|
||||||
|
|
||||||
|
const RecenterMap = ({
|
||||||
|
bounds,
|
||||||
|
currentLocation,
|
||||||
|
}: {
|
||||||
|
bounds: LatLngTuple[];
|
||||||
|
currentLocation: LatLngTuple | null;
|
||||||
|
}) => {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentLocation) {
|
||||||
|
map.setView(currentLocation, CURRENT_LOCATION_ZOOM, { animate: false });
|
||||||
|
} else if (bounds.length > 0) {
|
||||||
|
map.fitBounds(bounds, { padding: [32, 32], maxZoom: 6, animate: false });
|
||||||
|
}
|
||||||
|
}, [map, bounds, currentLocation]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BreweryMap = ({ breweries }: BreweryMapProps) => {
|
||||||
|
const currentLocation = useCurrentLocation();
|
||||||
|
const bounds: LatLngTuple[] = breweries.map((brewery) => [brewery.latitude, brewery.longitude]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={currentLocation ?? bounds[0] ?? FALLBACK_CENTER}
|
||||||
|
zoom={4}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
maxBounds={WORLD_BOUNDS}
|
||||||
|
maxBoundsViscosity={1.0}
|
||||||
|
worldCopyJump={false}
|
||||||
|
className="h-112 md:h-136 w-full rounded-box relative z-0"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
noWrap
|
||||||
|
/>
|
||||||
|
<RecenterMap bounds={bounds} currentLocation={currentLocation} />
|
||||||
|
{currentLocation && <CurrentLocationMarker position={currentLocation} />}
|
||||||
|
{breweries.map((brewery) => (
|
||||||
|
<Marker key={brewery.id} position={[brewery.latitude, brewery.longitude]}>
|
||||||
|
<Popup>
|
||||||
|
<p className="font-semibold">{brewery.name}</p>
|
||||||
|
{brewery.location && (
|
||||||
|
<p className="text-sm text-base-content/70">
|
||||||
|
{formatBreweryAddress(brewery.location)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<a href={`/breweries/${brewery.id}`} className="link link-primary text-sm">
|
||||||
|
View brewery →
|
||||||
|
</a>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BreweryMap;
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||||
|
import FeaturedBreweryCard from './FeaturedBreweryCard';
|
||||||
|
import type { Brewery, BreweryLocation } from '../../breweries.server';
|
||||||
|
|
||||||
|
const featuredBreweryCardDescription = `Larger "featured brewery" card shown above the fold on the breweries page. These stories cover a brewery with mappable coordinates (the "Show on map" button is present and wired to \`onShowOnMap\`), one with a location but no coordinates, and one with no location at all.`;
|
||||||
|
|
||||||
|
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 breweryWithLocation: 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 breweryWithoutCoordinates: Brewery = {
|
||||||
|
...breweryWithLocation,
|
||||||
|
breweryPostId: 'brewery-2',
|
||||||
|
breweryName: 'Foggy Bottom Brewhouse',
|
||||||
|
location: { ...portlandLocation, coordinates: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const breweryWithoutLocation: Brewery = {
|
||||||
|
...breweryWithLocation,
|
||||||
|
breweryPostId: 'brewery-3',
|
||||||
|
breweryName: 'Unmapped Ales',
|
||||||
|
location: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/FeaturedBreweryCard',
|
||||||
|
component: FeaturedBreweryCard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
args: {
|
||||||
|
onShowOnMap: fn(),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: featuredBreweryCardDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof FeaturedBreweryCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const WithCoordinates: Story = {
|
||||||
|
args: { brewery: breweryWithLocation },
|
||||||
|
play: async ({ args, canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(
|
||||||
|
canvas.getByRole('heading', { name: 'Cascade Hollow Brewing', level: 3 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('Founded')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('2014')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByRole('link', { name: /view brewery/i })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/breweries/brewery-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(canvas.getByRole('button', { name: /show on map/i }));
|
||||||
|
await expect(args.onShowOnMap).toHaveBeenCalledOnce();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutCoordinates: Story = {
|
||||||
|
args: { brewery: breweryWithoutCoordinates },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(
|
||||||
|
canvas.queryByRole('button', { name: /show on map/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutLocation: Story = {
|
||||||
|
args: { brewery: breweryWithoutLocation },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByRole('heading', { name: 'Unmapped Ales' })).toBeInTheDocument();
|
||||||
|
await expect(
|
||||||
|
canvas.queryByRole('button', { name: /show on map/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import type { Brewery } from '../../breweries.server';
|
||||||
|
import { formatBreweryAddress } from '../../utils/format-address';
|
||||||
|
|
||||||
|
interface FeaturedBreweryCardProps {
|
||||||
|
brewery: Brewery;
|
||||||
|
onShowOnMap: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// see BREWERY_HANDOFF.md
|
||||||
|
const FILLER_STATS = [
|
||||||
|
{ label: 'Founded', value: '2014' },
|
||||||
|
{ label: 'Beers listed', value: '18' },
|
||||||
|
{ label: 'House style', value: 'Seasonal ale' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FeaturedBreweryCard = ({ brewery, onShowOnMap }: FeaturedBreweryCardProps) => (
|
||||||
|
<div className="card bg-base-100 shadow-md rounded-box">
|
||||||
|
<div className="card-body gap-3.5 p-8 md:grid md:grid-cols-[1.35fr_1fr] md:gap-10 md:items-start">
|
||||||
|
<div className="flex flex-col gap-3.5">
|
||||||
|
{brewery.location && (
|
||||||
|
<div className="text-sm font-semibold text-base-content/60">
|
||||||
|
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="font-serif text-4xl leading-tight m-0">{brewery.breweryName}</h3>
|
||||||
|
<p className="text-lg leading-snug text-pretty m-0">{brewery.description}</p>
|
||||||
|
{brewery.location && (
|
||||||
|
<p className="text-sm text-base-content/60 m-0">
|
||||||
|
{formatBreweryAddress(brewery.location)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Link to={`/breweries/${brewery.breweryPostId}`} className="btn btn-primary">
|
||||||
|
View brewery
|
||||||
|
</Link>
|
||||||
|
{brewery.location?.coordinates && (
|
||||||
|
<button type="button" onClick={onShowOnMap} className="btn btn-outline">
|
||||||
|
Show on map
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats stats-vertical shadow w-full mt-6 md:mt-0">
|
||||||
|
{FILLER_STATS.map((stat) => (
|
||||||
|
<div key={stat.label} className="stat">
|
||||||
|
<div className="stat-title text-xs font-bold uppercase tracking-widest">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
|
<div className="stat-value font-serif text-2xl">{stat.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default FeaturedBreweryCard;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
import RecentBreweries from './RecentBreweries';
|
||||||
|
import type { Brewery, BreweryLocation } from '../../breweries.server';
|
||||||
|
|
||||||
|
const recentBreweriesDescription = `Grid of recently-added breweries shown on the breweries index page. Renders nothing when the list is empty.`;
|
||||||
|
|
||||||
|
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 cascadeHollow: Brewery = {
|
||||||
|
breweryPostId: 'brewery-1',
|
||||||
|
postedById: 'user-1',
|
||||||
|
breweryName: 'Cascade Hollow Brewing',
|
||||||
|
description: 'A small-batch brewery known for hazy IPAs and barrel-aged sours.',
|
||||||
|
createdAt: '2024-03-01T12:00:00.000Z',
|
||||||
|
updatedAt: null,
|
||||||
|
location: portlandLocation,
|
||||||
|
};
|
||||||
|
|
||||||
|
const midrangeMalts: Brewery = {
|
||||||
|
breweryPostId: 'brewery-2',
|
||||||
|
postedById: 'user-2',
|
||||||
|
breweryName: 'Midrange Malts',
|
||||||
|
description: 'A neighborhood taproom pouring approachable session ales year-round.',
|
||||||
|
createdAt: '2024-04-15T09:30:00.000Z',
|
||||||
|
updatedAt: null,
|
||||||
|
location: { ...portlandLocation, breweryPostId: 'brewery-2', cityName: 'Salem' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const unmappedAles: Brewery = {
|
||||||
|
breweryPostId: 'brewery-3',
|
||||||
|
postedById: 'user-3',
|
||||||
|
breweryName: 'Unmapped Ales',
|
||||||
|
description: 'A pop-up brewery that hasn’t added a storefront location yet.',
|
||||||
|
createdAt: '2024-05-02T18:00:00.000Z',
|
||||||
|
updatedAt: null,
|
||||||
|
location: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/RecentBreweries',
|
||||||
|
component: RecentBreweries,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: recentBreweriesDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof RecentBreweries>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const WithBreweries: Story = {
|
||||||
|
args: { breweries: [cascadeHollow, midrangeMalts, unmappedAles] },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByRole('heading', { name: 'Recently added' })).toBeInTheDocument();
|
||||||
|
await expect(
|
||||||
|
canvas.getByRole('heading', { name: 'Cascade Hollow Brewing' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
const links = canvas.getAllByRole('link', { name: /view brewery/i });
|
||||||
|
await expect(links).toHaveLength(3);
|
||||||
|
await expect(links[0]).toHaveAttribute('href', '/breweries/brewery-1');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: { breweries: [] },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(
|
||||||
|
canvas.queryByRole('heading', { name: 'Recently added' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import type { Brewery } from '../../breweries.server';
|
||||||
|
|
||||||
|
interface RecentBreweriesProps {
|
||||||
|
breweries: Brewery[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
|
||||||
|
if (breweries.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="max-w-7xl mx-auto px-5 pt-11">
|
||||||
|
<h2 className="font-serif text-2xl mb-3.5">Recently added</h2>
|
||||||
|
<div className="grid gap-5 grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]">
|
||||||
|
{breweries.map((brewery) => (
|
||||||
|
<div key={brewery.breweryPostId} className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body gap-1.5">
|
||||||
|
<h3 className="card-title font-serif text-xl">{brewery.breweryName}</h3>
|
||||||
|
{brewery.location && (
|
||||||
|
<div className="text-sm text-base-content/60">
|
||||||
|
{brewery.location.cityName},{' '}
|
||||||
|
{brewery.location.stateProvinceCode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm leading-snug mt-1 line-clamp-2">
|
||||||
|
{brewery.description}
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to={`/breweries/${brewery.breweryPostId}`}
|
||||||
|
className="link link-hover text-sm font-semibold text-secondary hover:text-primary mt-2"
|
||||||
|
>
|
||||||
|
View brewery →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecentBreweries;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { createMemoryRouter, RouterProvider } from 'react-router';
|
||||||
|
import { expect, fireEvent, waitFor, within } from 'storybook/test';
|
||||||
|
import NearbyBreweriesSection from './NearbyBreweriesSection';
|
||||||
|
import type { BreweryLocation, SimplifiedBrewery } from '../../../breweries.server';
|
||||||
|
|
||||||
|
const nearbyBreweriesSectionDescription = `Requests geolocation (falling back to \`fallbackCenter\` when it's unavailable or denied, as it reliably is in headless browser tests), then loads nearby breweries via \`useFetcher().load()\` against the \`/breweries/nearby\` route, re-fetching whenever the debounced search radius changes. This harness runs the real component behind a \`createMemoryRouter\` with a fake loader for that route, keyed by the requested radius, so the \`Default\` story's play function can prove both the initial load and that rapid radius changes are debounced into a single re-fetch rather than one per step. The lazily-loaded Leaflet map mounts for real here (Storybook's client-only render means \`ClientOnly\` reports "mounted" immediately) and will issue real tile requests - the play function doesn't assert anything about it.`;
|
||||||
|
|
||||||
|
const portlandLocation: BreweryLocation = {
|
||||||
|
breweryPostLocationId: 'loc-close',
|
||||||
|
breweryPostId: 'brewery-close',
|
||||||
|
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.52, longitude: -122.68 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeQuarters: SimplifiedBrewery = {
|
||||||
|
breweryPostId: 'brewery-close',
|
||||||
|
breweryName: 'Close Quarters Brewing',
|
||||||
|
location: portlandLocation,
|
||||||
|
distanceMetres: 4800,
|
||||||
|
};
|
||||||
|
|
||||||
|
const midrangeMalts: SimplifiedBrewery = {
|
||||||
|
breweryPostId: 'brewery-mid',
|
||||||
|
breweryName: 'Midrange Malts',
|
||||||
|
location: {
|
||||||
|
...portlandLocation,
|
||||||
|
breweryPostLocationId: 'loc-mid',
|
||||||
|
breweryPostId: 'brewery-mid',
|
||||||
|
cityName: 'Salem',
|
||||||
|
coordinates: { latitude: 44.94, longitude: -123.03 },
|
||||||
|
},
|
||||||
|
distanceMetres: 52000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const farFlungFermentery: SimplifiedBrewery = {
|
||||||
|
breweryPostId: 'brewery-far',
|
||||||
|
breweryName: 'Far Flung Fermentery',
|
||||||
|
location: {
|
||||||
|
...portlandLocation,
|
||||||
|
breweryPostLocationId: 'loc-far',
|
||||||
|
breweryPostId: 'brewery-far',
|
||||||
|
cityName: 'Bend',
|
||||||
|
coordinates: { latitude: 44.06, longitude: -121.31 },
|
||||||
|
},
|
||||||
|
distanceMetres: 138000,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_CENTER = { latitude: 45.5152, longitude: -122.6784, label: 'Portland, OR' };
|
||||||
|
|
||||||
|
// Keyed by rangeInMetres so the play function can prove the radius slider
|
||||||
|
// drives refetches. DEFAULT_RADIUS_KM=120 -> 120000; MAX_RADIUS_KM=150 -> 150000.
|
||||||
|
const NEARBY_FIXTURES: Record<number, SimplifiedBrewery[]> = {
|
||||||
|
30000: [closeQuarters],
|
||||||
|
60000: [closeQuarters],
|
||||||
|
90000: [closeQuarters, midrangeMalts],
|
||||||
|
120000: [closeQuarters, midrangeMalts],
|
||||||
|
150000: [closeQuarters, midrangeMalts, farFlungFermentery],
|
||||||
|
};
|
||||||
|
|
||||||
|
let fetchedRanges: number[] = [];
|
||||||
|
|
||||||
|
const NearbyBreweriesRoute = () => {
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
return (
|
||||||
|
<NearbyBreweriesSection
|
||||||
|
featured={null}
|
||||||
|
fallbackCenter={FALLBACK_CENTER}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NearbyBreweriesHarness = () => {
|
||||||
|
const [router] = useState(() => {
|
||||||
|
fetchedRanges = [];
|
||||||
|
return createMemoryRouter([
|
||||||
|
{ path: '/', element: <NearbyBreweriesRoute /> },
|
||||||
|
{
|
||||||
|
path: '/breweries/nearby',
|
||||||
|
loader: async ({ request }) => {
|
||||||
|
const rangeInMetres = Number(
|
||||||
|
new URL(request.url).searchParams.get('rangeInMetres'),
|
||||||
|
);
|
||||||
|
fetchedRanges.push(rangeInMetres);
|
||||||
|
return { breweries: NEARBY_FIXTURES[rangeInMetres] ?? [] };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NearbyBreweriesEmptyHarness = () => {
|
||||||
|
const [router] = useState(() =>
|
||||||
|
createMemoryRouter([
|
||||||
|
{ path: '/', element: <NearbyBreweriesRoute /> },
|
||||||
|
{
|
||||||
|
path: '/breweries/nearby',
|
||||||
|
loader: async () => ({ breweries: [] as SimplifiedBrewery[] }),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
return <RouterProvider router={router} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/NearbyBreweriesSection',
|
||||||
|
component: NearbyBreweriesHarness,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
usesDataRouter: true,
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: nearbyBreweriesSectionDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof NearbyBreweriesHarness>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
|
||||||
|
// Initial (undebounced) fetch at the default radius (120km -> 120000m).
|
||||||
|
await waitFor(
|
||||||
|
() => expect(canvas.getByText('Close Quarters Brewing')).toBeInTheDocument(),
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
await expect(canvas.getByText('Midrange Malts')).toBeInTheDocument();
|
||||||
|
await expect(fetchedRanges).toEqual([120000]);
|
||||||
|
|
||||||
|
// Simulate a fast drag through several intermediate values with no
|
||||||
|
// waits in between.
|
||||||
|
const radiusSlider = canvas.getByRole('slider', { name: /search radius/i });
|
||||||
|
fireEvent.change(radiusSlider, { target: { value: '30' } });
|
||||||
|
fireEvent.change(radiusSlider, { target: { value: '60' } });
|
||||||
|
fireEvent.change(radiusSlider, { target: { value: '90' } });
|
||||||
|
fireEvent.change(radiusSlider, { target: { value: '150' } });
|
||||||
|
|
||||||
|
// The radius label updates immediately (it isn't debounced)...
|
||||||
|
await expect(canvas.getByText('150 km')).toBeInTheDocument();
|
||||||
|
// ...but no new fetch has fired yet - the 300ms debounce window hasn't elapsed.
|
||||||
|
await expect(fetchedRanges).toEqual([120000]);
|
||||||
|
|
||||||
|
// After the debounce window, exactly one more fetch happens, for the
|
||||||
|
// final radius only - not one per intermediate step.
|
||||||
|
await waitFor(() => expect(canvas.getByText('Far Flung Fermentery')).toBeInTheDocument(), {
|
||||||
|
timeout: 3000,
|
||||||
|
});
|
||||||
|
await expect(fetchedRanges).toEqual([120000, 150000]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoBreweriesWithinRadius: Story = {
|
||||||
|
render: () => <NearbyBreweriesEmptyHarness />,
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await waitFor(
|
||||||
|
() => expect(canvas.getByText(/no partner breweries within/i)).toBeInTheDocument(),
|
||||||
|
{ timeout: 3000 },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { lazy, Suspense, useEffect, useState } from 'react';
|
||||||
|
import { useFetcher } from 'react-router';
|
||||||
|
import type { LatLngTuple } from 'leaflet';
|
||||||
|
import useDebouncedValue from '../../../../../hooks/useDebouncedValue';
|
||||||
|
import type { SimplifiedBrewery } from '../../../breweries.server';
|
||||||
|
import type { loader as nearbyLoader } from '../../../routes/resources/breweries-nearby';
|
||||||
|
import type { DistanceUnit } from '../../../utils/distance';
|
||||||
|
import ChangeLocationButton from './controls/ChangeLocationButton';
|
||||||
|
import RadiusUnitControls from './controls/RadiusUnitControls';
|
||||||
|
import NearbyBreweriesHeader from './header/NearbyBreweriesHeader';
|
||||||
|
import NearbyBreweryList from './list/NearbyBreweryList';
|
||||||
|
import ClientOnlyMap, { MAP_PLACEHOLDER_CLASS } from './map/ClientOnlyMap';
|
||||||
|
import type { NearbyMapPin } from './map/NearbyBreweryMap';
|
||||||
|
|
||||||
|
const NearbyBreweryMap = lazy(() => import('./map/NearbyBreweryMap'));
|
||||||
|
|
||||||
|
const DEFAULT_RADIUS_KM = 120;
|
||||||
|
const RADIUS_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export interface FeaturedPin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FallbackCenter {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NearbyBreweriesSectionProps {
|
||||||
|
featured: FeaturedPin | null;
|
||||||
|
fallbackCenter: FallbackCenter | null;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NearbyBreweriesSection = ({
|
||||||
|
featured,
|
||||||
|
fallbackCenter,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: NearbyBreweriesSectionProps) => {
|
||||||
|
const [userLocation, setUserLocation] = useState<LatLngTuple | null>(null);
|
||||||
|
const [locationLabel, setLocationLabel] = useState('your location');
|
||||||
|
const [geoAttempted, setGeoAttempted] = useState(false);
|
||||||
|
const [radiusKm, setRadiusKm] = useState(DEFAULT_RADIUS_KM);
|
||||||
|
const debouncedRadiusKm = useDebouncedValue(radiusKm, RADIUS_DEBOUNCE_MS);
|
||||||
|
const [unit, setUnit] = useState<DistanceUnit>('km');
|
||||||
|
const fetcher = useFetcher<typeof nearbyLoader>();
|
||||||
|
|
||||||
|
const requestGeolocation = () => {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
Promise.resolve().then(() => setGeoAttempted(true));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
|
||||||
|
setLocationLabel('your location');
|
||||||
|
setGeoAttempted(true);
|
||||||
|
},
|
||||||
|
() => setGeoAttempted(true),
|
||||||
|
{ enableHighAccuracy: true },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
requestGeolocation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const center: LatLngTuple | null =
|
||||||
|
userLocation ??
|
||||||
|
(geoAttempted && fallbackCenter
|
||||||
|
? [fallbackCenter.latitude, fallbackCenter.longitude]
|
||||||
|
: null);
|
||||||
|
const centerLabel = userLocation ? locationLabel : (fallbackCenter?.label ?? locationLabel);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!center) return;
|
||||||
|
fetcher.load(
|
||||||
|
`/breweries/nearby?latitude=${center[0]}&longitude=${center[1]}&rangeInMetres=${debouncedRadiusKm * 1000}`,
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [center?.[0], center?.[1], debouncedRadiusKm]);
|
||||||
|
|
||||||
|
const nearby: SimplifiedBrewery[] = fetcher.data?.breweries ?? [];
|
||||||
|
const withDistance = center
|
||||||
|
? nearby
|
||||||
|
.filter((b) => b.location?.coordinates && b.distanceMetres !== null)
|
||||||
|
.map((b) => ({
|
||||||
|
brewery: b,
|
||||||
|
distanceMetres: b.distanceMetres as number,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.distanceMetres - b.distanceMetres)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const pins: NearbyMapPin[] = withDistance.map(({ brewery }) => ({
|
||||||
|
id: brewery.breweryPostId,
|
||||||
|
name: brewery.breweryName,
|
||||||
|
cityLabel: brewery.location
|
||||||
|
? `${brewery.location.cityName}, ${brewery.location.stateProvinceCode}`
|
||||||
|
: null,
|
||||||
|
latitude: brewery.location!.coordinates!.latitude,
|
||||||
|
longitude: brewery.location!.coordinates!.longitude,
|
||||||
|
}));
|
||||||
|
if (featured && !pins.some((p) => p.id === featured.id)) {
|
||||||
|
pins.push({
|
||||||
|
id: featured.id,
|
||||||
|
name: featured.name,
|
||||||
|
cityLabel: null,
|
||||||
|
latitude: featured.latitude,
|
||||||
|
longitude: featured.longitude,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="breweries-near-you" className="max-w-7xl mx-auto px-5 pt-11">
|
||||||
|
<NearbyBreweriesHeader
|
||||||
|
center={center}
|
||||||
|
withDistance={withDistance}
|
||||||
|
radiusKm={radiusKm}
|
||||||
|
unit={unit}
|
||||||
|
centerLabel={centerLabel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 items-stretch md:grid-cols-[22rem_1fr]">
|
||||||
|
<div className="flex flex-col gap-3 order-2 md:order-1">
|
||||||
|
<RadiusUnitControls
|
||||||
|
radiusKm={radiusKm}
|
||||||
|
unit={unit}
|
||||||
|
onRadiusChange={(e) => setRadiusKm(Number(e.target.value))}
|
||||||
|
onSelectKm={() => setUnit('km')}
|
||||||
|
onSelectMi={() => setUnit('mi')}
|
||||||
|
/>
|
||||||
|
<NearbyBreweryList
|
||||||
|
withDistance={withDistance}
|
||||||
|
center={center}
|
||||||
|
radiusKm={radiusKm}
|
||||||
|
unit={unit}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
<ChangeLocationButton onClick={requestGeolocation} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="order-1 md:order-2">
|
||||||
|
<ClientOnlyMap
|
||||||
|
render={() =>
|
||||||
|
center ? (
|
||||||
|
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
|
||||||
|
<NearbyBreweryMap
|
||||||
|
pins={pins}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
userLocation={userLocation}
|
||||||
|
userLocationLabel={centerLabel}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
) : (
|
||||||
|
<div className={MAP_PLACEHOLDER_CLASS} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NearbyBreweriesSection;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
interface ChangeLocationButtonProps {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChangeLocationButton = ({ onClick }: ChangeLocationButtonProps) => {
|
||||||
|
return (
|
||||||
|
<div className="pt-1">
|
||||||
|
<button type="button" onClick={onClick} className="btn btn-ghost btn-sm">
|
||||||
|
Change location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChangeLocationButton;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { formatDistance, type DistanceUnit } from '../../../../utils/distance';
|
||||||
|
|
||||||
|
const MIN_RADIUS_KM = 10;
|
||||||
|
const MAX_RADIUS_KM = 300;
|
||||||
|
const RADIUS_STEP_KM = 10;
|
||||||
|
|
||||||
|
interface RadiusUnitControlsProps {
|
||||||
|
radiusKm: number;
|
||||||
|
unit: DistanceUnit;
|
||||||
|
onRadiusChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onSelectKm: () => void;
|
||||||
|
onSelectMi: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RadiusUnitControls = ({
|
||||||
|
radiusKm,
|
||||||
|
unit,
|
||||||
|
onRadiusChange,
|
||||||
|
onSelectKm,
|
||||||
|
onSelectMi,
|
||||||
|
}: RadiusUnitControlsProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<label className="flex-1 flex flex-col gap-1 text-sm text-base-content/70">
|
||||||
|
<span className="flex justify-between">
|
||||||
|
<span>Search radius</span>
|
||||||
|
<span className="font-semibold">{formatDistance(radiusKm * 1000, unit)}</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={MIN_RADIUS_KM}
|
||||||
|
max={MAX_RADIUS_KM}
|
||||||
|
step={RADIUS_STEP_KM}
|
||||||
|
value={radiusKm}
|
||||||
|
onChange={onRadiusChange}
|
||||||
|
className="range range-primary range-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="join self-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelectKm}
|
||||||
|
className={`btn btn-xs join-item ${unit === 'km' ? 'btn-active' : ''}`}
|
||||||
|
>
|
||||||
|
km
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelectMi}
|
||||||
|
className={`btn btn-xs join-item ${unit === 'mi' ? 'btn-active' : ''}`}
|
||||||
|
>
|
||||||
|
mi
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RadiusUnitControls;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { LatLngTuple } from 'leaflet';
|
||||||
|
import { formatDistance, type DistanceUnit } from '../../../../utils/distance';
|
||||||
|
import type { BreweryWithDistance } from '../types';
|
||||||
|
|
||||||
|
interface NearbyBreweriesHeaderProps {
|
||||||
|
center: LatLngTuple | null;
|
||||||
|
withDistance: BreweryWithDistance[];
|
||||||
|
radiusKm: number;
|
||||||
|
unit: DistanceUnit;
|
||||||
|
centerLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NearbyBreweriesHeader = ({
|
||||||
|
center,
|
||||||
|
centerLabel,
|
||||||
|
radiusKm,
|
||||||
|
unit,
|
||||||
|
withDistance,
|
||||||
|
}: NearbyBreweriesHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-3.5">
|
||||||
|
<h2 className="font-serif text-2xl m-0">Breweries near you</h2>
|
||||||
|
<span className="text-sm text-base-content/60">
|
||||||
|
{center
|
||||||
|
? `${withDistance.length} within ${formatDistance(radiusKm * 1000, unit)} of ${centerLabel}`
|
||||||
|
: 'Finding breweries near you…'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NearbyBreweriesHeader;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { LatLngTuple } from 'leaflet';
|
||||||
|
import { formatDistance, type DistanceUnit } from '../../../../utils/distance';
|
||||||
|
import type { BreweryWithDistance } from '../types';
|
||||||
|
import NearbyBreweryListItem from './NearbyBreweryListItem';
|
||||||
|
|
||||||
|
interface NearbyBreweryListProps {
|
||||||
|
withDistance: BreweryWithDistance[];
|
||||||
|
center: LatLngTuple | null;
|
||||||
|
radiusKm: number;
|
||||||
|
unit: DistanceUnit;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NearbyBreweryList = ({
|
||||||
|
withDistance,
|
||||||
|
center,
|
||||||
|
radiusKm,
|
||||||
|
unit,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
}: NearbyBreweryListProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{withDistance.length === 0 && center && (
|
||||||
|
<p className="text-sm text-base-content/60">
|
||||||
|
No partner breweries within {formatDistance(radiusKm * 1000, unit)}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{withDistance.map(({ brewery, distanceMetres }) => (
|
||||||
|
<NearbyBreweryListItem
|
||||||
|
key={brewery.breweryPostId}
|
||||||
|
brewery={brewery}
|
||||||
|
distanceMetres={distanceMetres}
|
||||||
|
unit={unit}
|
||||||
|
selected={selectedId === brewery.breweryPostId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NearbyBreweryList;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SimplifiedBrewery } from '../../../../breweries.server';
|
||||||
|
import { formatDistance, type DistanceUnit } from '../../../../utils/distance';
|
||||||
|
|
||||||
|
// Notes aren't tracked on brewery locations yet - see BREWERY_HANDOFF.md.
|
||||||
|
const FILLER_NOTE = 'A stop worth the detour.';
|
||||||
|
|
||||||
|
const cardClassName = (selected: boolean) =>
|
||||||
|
`card card-sm card-border bg-base-100 text-left shadow transition-shadow hover:shadow-lg ${
|
||||||
|
selected ? 'border-2 border-primary' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
|
interface NearbyBreweryListItemProps {
|
||||||
|
brewery: SimplifiedBrewery;
|
||||||
|
distanceMetres: number;
|
||||||
|
unit: DistanceUnit;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NearbyBreweryListItem = ({
|
||||||
|
brewery,
|
||||||
|
distanceMetres,
|
||||||
|
unit,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: NearbyBreweryListItemProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(brewery.breweryPostId)}
|
||||||
|
className={cardClassName(selected)}
|
||||||
|
>
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="flex justify-between items-baseline gap-3">
|
||||||
|
<h3 className="card-title font-serif text-lg">{brewery.breweryName}</h3>
|
||||||
|
<span className="text-sm font-semibold text-primary whitespace-nowrap">
|
||||||
|
{formatDistance(distanceMetres, unit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{brewery.location && (
|
||||||
|
<div className="text-sm text-base-content/60">
|
||||||
|
{brewery.location.cityName}, {brewery.location.stateProvinceCode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-sm mt-2">{FILLER_NOTE}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NearbyBreweryListItem;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import ClientOnly from '../../../../../../components/ClientOnly';
|
||||||
|
|
||||||
|
const MAP_PLACEHOLDER_CLASS = 'skeleton min-h-[30rem] h-full w-full rounded-box';
|
||||||
|
|
||||||
|
interface ClientOnlyMapProps {
|
||||||
|
render: () => React.JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientOnlyMap = ({ render }: ClientOnlyMapProps) => {
|
||||||
|
return <ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>{render}</ClientOnly>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientOnlyMap;
|
||||||
|
export { MAP_PLACEHOLDER_CLASS };
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import NearbyBreweryMap, { type NearbyMapPin } from './NearbyBreweryMap';
|
||||||
|
|
||||||
|
const nearbyBreweryMapDescription = `Leaflet map of nearby-brewery pins used inside NearbyBreweriesSection. Smoke-render only - this hits the real OpenStreetMap tile server in the browser test run. Wrapped in a fixed-height container here since its \`h-full\` class normally relies on a CSS grid ancestor that this story doesn't provide.`;
|
||||||
|
|
||||||
|
const pins: NearbyMapPin[] = [
|
||||||
|
{
|
||||||
|
id: 'brewery-close',
|
||||||
|
name: 'Close Quarters Brewing',
|
||||||
|
cityLabel: 'Portland, OR',
|
||||||
|
latitude: 45.52,
|
||||||
|
longitude: -122.68,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'brewery-mid',
|
||||||
|
name: 'Midrange Malts',
|
||||||
|
cityLabel: 'Salem, OR',
|
||||||
|
latitude: 44.94,
|
||||||
|
longitude: -123.03,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/NearbyBreweryMap',
|
||||||
|
component: NearbyBreweryMap,
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div className="h-[32rem]">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: nearbyBreweryMapDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof NearbyBreweryMap>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
pins,
|
||||||
|
selectedId: null,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
userLocation: [45.5152, -122.6784],
|
||||||
|
userLocationLabel: 'your location',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutUserLocation: Story = {
|
||||||
|
args: {
|
||||||
|
pins,
|
||||||
|
selectedId: null,
|
||||||
|
onSelect: () => undefined,
|
||||||
|
userLocation: null,
|
||||||
|
userLocationLabel: 'your location',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { MapContainer, TileLayer, Marker, Popup, CircleMarker, useMap } from 'react-leaflet';
|
||||||
|
import L, { type LatLngTuple } from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
const DEFAULT_ZOOM = 11;
|
||||||
|
const MIN_ZOOM = 8;
|
||||||
|
|
||||||
|
export interface NearbyMapPin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cityLabel: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NearbyBreweryMapProps {
|
||||||
|
pins: NearbyMapPin[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
userLocation: LatLngTuple | null;
|
||||||
|
userLocationLabel: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaflet paints markers as SVG attributes, which can't reference CSS custom
|
||||||
|
// properties the way class names can - resolve the current theme's colors instead.
|
||||||
|
const resolveThemeColor = (variable: string, fallback: string) => {
|
||||||
|
if (typeof document === 'undefined') return fallback;
|
||||||
|
const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
|
||||||
|
return value || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pinIcon = (active: boolean) =>
|
||||||
|
L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<span class="block h-[18px] w-[18px] rounded-full border-[3px] border-base-100 shadow-[0_1px_4px_rgba(0,0,0,.35)] ${
|
||||||
|
active ? 'bg-secondary scale-125' : 'bg-primary'
|
||||||
|
}"></span>`,
|
||||||
|
iconSize: [18, 18],
|
||||||
|
iconAnchor: [9, 9],
|
||||||
|
});
|
||||||
|
|
||||||
|
const FitAndFocus = ({
|
||||||
|
pins,
|
||||||
|
userLocation,
|
||||||
|
selectedId,
|
||||||
|
}: {
|
||||||
|
pins: NearbyMapPin[];
|
||||||
|
userLocation: LatLngTuple | null;
|
||||||
|
selectedId: string | null;
|
||||||
|
}) => {
|
||||||
|
const map = useMap();
|
||||||
|
const didInitialFit = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInitialFit.current) return;
|
||||||
|
const focus: LatLngTuple | null =
|
||||||
|
userLocation ?? (pins[0] ? [pins[0].latitude, pins[0].longitude] : null);
|
||||||
|
if (focus) {
|
||||||
|
map.setView(focus, DEFAULT_ZOOM, { animate: false });
|
||||||
|
didInitialFit.current = true;
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => map.invalidateSize(), 200);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [map, pins, userLocation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitialFit.current || !selectedId) return;
|
||||||
|
const pin = pins.find((p) => p.id === selectedId);
|
||||||
|
if (!pin) return;
|
||||||
|
map.flyTo([pin.latitude, pin.longitude], Math.max(map.getZoom(), 11), { duration: 0.8 });
|
||||||
|
}, [map, pins, selectedId]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RecenterButton = ({ userLocation }: { userLocation: LatLngTuple | null }) => {
|
||||||
|
const map = useMap();
|
||||||
|
if (!userLocation) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => map.setView(userLocation, DEFAULT_ZOOM, { animate: true })}
|
||||||
|
className="btn btn-circle btn-sm bg-base-100 absolute top-2.5 right-2.5 z-[1000] shadow"
|
||||||
|
aria-label="Recenter on your location"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="h-4 w-4"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const NearbyBreweryMap = ({
|
||||||
|
pins,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
userLocation,
|
||||||
|
userLocationLabel,
|
||||||
|
}: NearbyBreweryMapProps) => (
|
||||||
|
<MapContainer
|
||||||
|
center={userLocation ?? [pins[0]?.latitude ?? 20, pins[0]?.longitude ?? 0]}
|
||||||
|
zoom={DEFAULT_ZOOM}
|
||||||
|
minZoom={MIN_ZOOM}
|
||||||
|
scrollWheelZoom={false}
|
||||||
|
className="min-h-[30rem] h-full w-full rounded-box relative z-0"
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<FitAndFocus pins={pins} userLocation={userLocation} selectedId={selectedId} />
|
||||||
|
<RecenterButton userLocation={userLocation} />
|
||||||
|
{userLocation && (
|
||||||
|
<CircleMarker
|
||||||
|
center={userLocation}
|
||||||
|
radius={6}
|
||||||
|
pathOptions={{
|
||||||
|
color: resolveThemeColor('--color-base-100', '#fff'),
|
||||||
|
weight: 2,
|
||||||
|
fillColor: resolveThemeColor('--color-primary', '#2b6cb0'),
|
||||||
|
fillOpacity: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>You are here — {userLocationLabel}</Popup>
|
||||||
|
</CircleMarker>
|
||||||
|
)}
|
||||||
|
{pins.map((pin) => (
|
||||||
|
<Marker
|
||||||
|
key={pin.id}
|
||||||
|
position={[pin.latitude, pin.longitude]}
|
||||||
|
icon={pinIcon(pin.id === selectedId)}
|
||||||
|
eventHandlers={{ click: () => onSelect(pin.id) }}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<p className="font-semibold font-serif">{pin.name}</p>
|
||||||
|
{pin.cityLabel && (
|
||||||
|
<p className="text-sm text-base-content/70">{pin.cityLabel}</p>
|
||||||
|
)}
|
||||||
|
<a href={`/breweries/${pin.id}`} className="link link-primary text-sm">
|
||||||
|
View brewery →
|
||||||
|
</a>
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default NearbyBreweryMap;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { SimplifiedBrewery } from '../../../breweries.server';
|
||||||
|
|
||||||
|
export interface BreweryWithDistance {
|
||||||
|
brewery: SimplifiedBrewery;
|
||||||
|
distanceMetres: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, within } from 'storybook/test';
|
||||||
|
import SidebarCard from './SidebarCard';
|
||||||
|
|
||||||
|
const sidebarCardDescription = `Shared card shell used by the brewery detail page's sidebar sections (Location, Details, Beer styles, Community) — an uppercase eyebrow label over arbitrary content.`;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/SidebarCard',
|
||||||
|
component: SidebarCard,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: sidebarCardDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof SidebarCard>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
title: 'Details',
|
||||||
|
children: <p className="text-sm m-0">Founded 2014</p>,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.getByText('Details')).toBeInTheDocument();
|
||||||
|
await expect(canvas.getByText('Founded 2014')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface SidebarCardProps {
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarCard = ({ title, children }: SidebarCardProps) => (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body gap-3 p-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-base-content/50 m-0">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SidebarCard;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect, fn, userEvent, within } from 'storybook/test';
|
||||||
|
import StarRating from './StarRating';
|
||||||
|
|
||||||
|
const starRatingDescription = `Dual-purpose star control built on daisyUI's \`rating\` component. Read-only (no \`onChange\`) when used for an average or a display rating; interactive otherwise — clicking the currently-selected star clears the rating, since daisyUI radios can't do that natively.`;
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Breweries/StarRating',
|
||||||
|
component: StarRating,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: starRatingDescription,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof StarRating>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const ReadOnly: Story = {
|
||||||
|
args: { value: 4, size: 'sm' },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await expect(canvas.queryByRole('radio')).not.toBeInTheDocument();
|
||||||
|
await expect(canvas.getByLabelText('4 out of 5 stars')).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
args: { value: 0, size: 'lg', onChange: fn() },
|
||||||
|
play: async ({ args, canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole('radio', { name: 'Rate 3 of 5' }));
|
||||||
|
await expect(args.onChange).toHaveBeenCalledWith(3);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InteractiveClearsOnRepeatClick: Story = {
|
||||||
|
args: { value: 3, size: 'lg', onChange: fn() },
|
||||||
|
play: async ({ args, canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
await userEvent.click(canvas.getByRole('radio', { name: 'Rate 3 of 5' }));
|
||||||
|
await expect(args.onChange).toHaveBeenCalledWith(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
|
||||||
|
interface StarRatingProps {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
onChange?: (value: number) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_CLASS: Record<NonNullable<StarRatingProps['size']>, string> = {
|
||||||
|
xs: 'rating-xs',
|
||||||
|
sm: 'rating-sm',
|
||||||
|
md: 'rating-md',
|
||||||
|
lg: 'rating-lg',
|
||||||
|
xl: 'rating-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Read-only when `onChange` is omitted, interactive otherwise. Clicking the
|
||||||
|
* currently-selected star clears the rating (daisyUI radios can't do this natively). */
|
||||||
|
const StarRating = ({ value, max = 5, size = 'sm', onChange, label }: StarRatingProps) => {
|
||||||
|
const name = useId();
|
||||||
|
const stars = Array.from({ length: max }, (_, i) => i + 1);
|
||||||
|
|
||||||
|
if (!onChange) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rating ${SIZE_CLASS[size]}`}
|
||||||
|
aria-label={label ?? `${value} out of ${max} stars`}
|
||||||
|
>
|
||||||
|
{stars.map((n) => (
|
||||||
|
<div
|
||||||
|
key={n}
|
||||||
|
className="mask mask-star bg-warning"
|
||||||
|
aria-label={`${n} star`}
|
||||||
|
aria-current={n === value ? 'true' : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rating ${SIZE_CLASS[size]}`}>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
className="rating-hidden"
|
||||||
|
checked={value === 0}
|
||||||
|
onChange={() => onChange(0)}
|
||||||
|
aria-label="Clear rating"
|
||||||
|
/>
|
||||||
|
{stars.map((n) => (
|
||||||
|
<input
|
||||||
|
key={n}
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
className="mask mask-star bg-warning"
|
||||||
|
checked={value === n}
|
||||||
|
onChange={() => onChange(n)}
|
||||||
|
onClick={() => {
|
||||||
|
if (value === n) onChange(0);
|
||||||
|
}}
|
||||||
|
aria-label={`Rate ${n} of ${max}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StarRating;
|
||||||
@@ -1,38 +1,101 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router';
|
||||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
import BreweryCard from '../components/BreweryCard';
|
import FeaturedBreweryCard from '../components/overview/FeaturedBreweryCard';
|
||||||
|
import NearbyBreweriesSection from '../components/overview/nearby/NearbyBreweriesSection';
|
||||||
|
import RecentBreweries from '../components/overview/RecentBreweries';
|
||||||
import { getBreweries } from '../breweries.server';
|
import { getBreweries } from '../breweries.server';
|
||||||
import type { Route } from './+types/breweries';
|
import type { Route } from './+types/breweries';
|
||||||
|
|
||||||
|
// No count endpoint exists yet - see BREWERY_HANDOFF.md.
|
||||||
|
const FILLER_TOTAL_BREWERY_COUNT = 128;
|
||||||
|
|
||||||
export const meta = ({}: Route.MetaArgs) => {
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
return [{ title: 'Breweries | The Biergarten App' }];
|
return [{ title: 'Breweries | The Biergarten App' }];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loader = async () => {
|
export const loader = async () => {
|
||||||
const breweries = await getBreweries();
|
// "Featured" is just the most recently posted brewery for now - see BREWERY_HANDOFF.md.
|
||||||
return { breweries };
|
const [featured, ...recent] = await getBreweries(4, 0);
|
||||||
|
return { featured: featured ?? null, recent };
|
||||||
};
|
};
|
||||||
|
|
||||||
const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||||
const { breweries } = loaderData;
|
const { featured, recent } = loaderData;
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const featuredPin = featured?.location?.coordinates
|
||||||
|
? {
|
||||||
|
id: featured.breweryPostId,
|
||||||
|
name: featured.breweryName,
|
||||||
|
latitude: featured.location.coordinates.latitude,
|
||||||
|
longitude: featured.location.coordinates.longitude,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const fallbackCenter = featured?.location?.coordinates
|
||||||
|
? {
|
||||||
|
latitude: featured.location.coordinates.latitude,
|
||||||
|
longitude: featured.location.coordinates.longitude,
|
||||||
|
label: featured.location.cityName,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200 text-base-content pb-16">
|
||||||
<div className="container mx-auto p-4">
|
<div className="max-w-7xl mx-auto px-5 pt-10">
|
||||||
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
|
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
|
||||||
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
|
<p className="text-lg text-base-content/60 max-w-xl m-0">
|
||||||
|
Discover our partner breweries — start with this week's feature, then see
|
||||||
|
what's pouring near you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{breweries.length === 0 ? (
|
{featured && (
|
||||||
<div className="alert alert-info alert-soft">
|
<section className="max-w-7xl mx-auto px-5 pt-8">
|
||||||
<span>No breweries have been posted yet.</span>
|
<div className="flex items-baseline gap-3 mb-3.5">
|
||||||
</div>
|
<h2 className="font-serif text-2xl m-0">Featured brewery</h2>
|
||||||
) : (
|
<span className="badge badge-primary badge-sm uppercase tracking-widest font-bold">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
This week
|
||||||
{breweries.map((brewery) => (
|
</span>
|
||||||
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<FeaturedBreweryCard
|
||||||
|
brewery={featured}
|
||||||
|
onShowOnMap={() => {
|
||||||
|
setSelectedId(featured.breweryPostId);
|
||||||
|
document
|
||||||
|
.getElementById('breweries-near-you')
|
||||||
|
?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<NearbyBreweriesSection
|
||||||
|
featured={featuredPin}
|
||||||
|
fallbackCenter={fallbackCenter}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={setSelectedId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RecentBreweries breweries={recent} />
|
||||||
|
|
||||||
|
<section className="max-w-7xl mx-auto px-5 pt-11">
|
||||||
|
<div className="bg-[var(--color-highlight)] text-[var(--color-highlight-content)] rounded-box p-7 flex items-center justify-between gap-6 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-serif text-2xl mb-1">
|
||||||
|
Looking for something specific?
|
||||||
|
</h2>
|
||||||
|
<p className="m-0">
|
||||||
|
All {FILLER_TOTAL_BREWERY_COUNT} partner breweries, filterable by
|
||||||
|
country, region, and beer style.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Link to="/breweries/directory" className="btn btn-primary">
|
||||||
|
Browse the full directory
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,55 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { data, Link } from 'react-router';
|
import { data, Link } from 'react-router';
|
||||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
|
import { getOptionalAuth } from '../../auth/auth.server';
|
||||||
import { getBreweryById } from '../breweries.server';
|
import { getBreweryById } from '../breweries.server';
|
||||||
|
import BreweryHeaderCard from '../components/detail/BreweryHeaderCard';
|
||||||
|
import YourRatingCard from '../components/detail/YourRatingCard';
|
||||||
|
import BeerListCard from '../components/detail/BeerListCard';
|
||||||
|
import CommentsCard from '../components/detail/comments/CommentsCard';
|
||||||
|
import LocationCard from '../components/detail/LocationCard';
|
||||||
|
import BreweryDetailsCard from '../components/detail/BreweryDetailsCard';
|
||||||
|
import BeerStyleBreakdownCard from '../components/detail/BeerStyleBreakdownCard';
|
||||||
|
import CommunityStatsCard from '../components/detail/CommunityStatsCard';
|
||||||
|
import { FILLER_BEERS, FILLER_BREWERY_META, FILLER_COMMENTS } from '../utils/filler-brewery-detail';
|
||||||
import type { Route } from './+types/brewery-detail';
|
import type { Route } from './+types/brewery-detail';
|
||||||
|
|
||||||
export const loader = async ({ params }: Route.LoaderArgs) => {
|
export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||||
const brewery = await getBreweryById(params.id);
|
const [brewery, auth] = await Promise.all([
|
||||||
|
getBreweryById(params.id),
|
||||||
|
getOptionalAuth(request),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!brewery) {
|
if (!brewery) {
|
||||||
throw data('Brewery not found.', { status: 404, statusText: 'Not Found' });
|
throw data('Brewery not found.', { status: 404, statusText: 'Not Found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { brewery };
|
return {
|
||||||
|
brewery,
|
||||||
|
loggedIn: auth !== null,
|
||||||
|
username: auth?.username ?? null,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initials = (username: string) => username.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
const BreweryDetail = ({ loaderData }: Route.ComponentProps) => {
|
||||||
const { brewery } = loaderData;
|
const { brewery, loggedIn, username } = loaderData;
|
||||||
|
|
||||||
|
const [liked, setLiked] = useState(false);
|
||||||
|
const [likeCount, setLikeCount] = useState(FILLER_BREWERY_META.likeCount);
|
||||||
|
const [yourRating, setYourRating] = useState(0);
|
||||||
|
const [ratingsCount, setRatingsCount] = useState(FILLER_BREWERY_META.ratingsCount);
|
||||||
|
const [comments, setComments] = useState(FILLER_COMMENTS);
|
||||||
|
|
||||||
|
const handleToggleLike = () => {
|
||||||
|
setLiked((prev) => !prev);
|
||||||
|
setLikeCount((prev) => prev + (liked ? -1 : 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRatingChange = (next: number) => {
|
||||||
|
setRatingsCount((prev) => prev + (next === 0 ? -1 : yourRating === 0 ? 1 : 0));
|
||||||
|
setYourRating(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddComment = (text: string) => {
|
||||||
|
setComments((prev) => [
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
user: username ?? 'you',
|
||||||
|
initials: username ? initials(username) : 'YO',
|
||||||
|
rating: yourRating,
|
||||||
|
time: 'just now',
|
||||||
|
text,
|
||||||
|
likes: 0,
|
||||||
|
liked: false,
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleCommentLike = (id: string) => {
|
||||||
|
setComments((prev) =>
|
||||||
|
prev.map((comment) =>
|
||||||
|
comment.id === id ? { ...comment, liked: !comment.liked } : comment,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-base-200">
|
<div className="min-h-screen bg-base-200">
|
||||||
<div className="container mx-auto p-4 max-w-2xl">
|
<div className="max-w-4xl mx-auto px-6 pt-8 pb-16">
|
||||||
<Link
|
<Link
|
||||||
to="/breweries"
|
to="/breweries"
|
||||||
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to Breweries
|
← Back to breweries
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="card bg-base-100 shadow">
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_19rem] gap-5 items-start">
|
||||||
<div className="card-body">
|
<div className="flex flex-col gap-5">
|
||||||
<h1 className="card-title text-3xl">{brewery.breweryName}</h1>
|
<BreweryHeaderCard
|
||||||
<p className="text-base-content/70 whitespace-pre-line">
|
brewery={brewery}
|
||||||
{brewery.description}
|
foundedYear={FILLER_BREWERY_META.foundedYear}
|
||||||
</p>
|
liked={liked}
|
||||||
|
likeCount={likeCount}
|
||||||
{brewery.location && (
|
avgRating={FILLER_BREWERY_META.avgRating}
|
||||||
<div className="bg-base-200 rounded-box p-4 mt-4">
|
ratingsCount={ratingsCount}
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
|
onToggleLike={handleToggleLike}
|
||||||
Location
|
/>
|
||||||
</p>
|
<YourRatingCard value={yourRating} onChange={handleRatingChange} />
|
||||||
<p>{brewery.location.addressLine1}</p>
|
<BeerListCard beers={FILLER_BEERS} />
|
||||||
{brewery.location.addressLine2 && (
|
<CommentsCard
|
||||||
<p>{brewery.location.addressLine2}</p>
|
comments={comments}
|
||||||
)}
|
loggedIn={loggedIn}
|
||||||
<p>{brewery.location.postalCode}</p>
|
currentUserInitials={username ? initials(username) : 'YO'}
|
||||||
|
onAddComment={handleAddComment}
|
||||||
|
onToggleCommentLike={handleToggleCommentLike}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-base-content/40 mt-4">
|
<div className="flex flex-col gap-5">
|
||||||
Posted {new Date(brewery.createdAt).toLocaleDateString()}
|
{brewery.location && (
|
||||||
{brewery.updatedAt &&
|
<LocationCard
|
||||||
` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`}
|
breweryName={brewery.breweryName}
|
||||||
</p>
|
location={brewery.location}
|
||||||
|
website={FILLER_BREWERY_META.website}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<BreweryDetailsCard
|
||||||
|
foundedYear={FILLER_BREWERY_META.foundedYear}
|
||||||
|
type={FILLER_BREWERY_META.type}
|
||||||
|
beerCount={FILLER_BEERS.length}
|
||||||
|
/>
|
||||||
|
<BeerStyleBreakdownCard beers={FILLER_BEERS} />
|
||||||
|
<CommunityStatsCard
|
||||||
|
likeCount={likeCount}
|
||||||
|
ratingsCount={ratingsCount}
|
||||||
|
commentCount={comments.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
121
web/frontend/app/features/breweries/routes/brewery-directory.tsx
Normal file
121
web/frontend/app/features/breweries/routes/brewery-directory.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||||
|
import ClientOnly from '../../../components/ClientOnly';
|
||||||
|
import BreweryCard from '../components/directory/BreweryCard';
|
||||||
|
import { getBreweries, getBreweryLocations, type SimplifiedBrewery } from '../breweries.server';
|
||||||
|
import type { Route } from './+types/brewery-directory';
|
||||||
|
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useFetcher } from 'react-router';
|
||||||
|
import type { BreweryMapPin } from '../components/directory/BreweryMap';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 12;
|
||||||
|
const MAP_PLACEHOLDER_CLASS = 'skeleton h-[28rem] md:h-[34rem] w-full rounded-box';
|
||||||
|
|
||||||
|
export const meta = ({}: Route.MetaArgs) => {
|
||||||
|
return [{ title: 'Brewery Directory | The Biergarten App' }];
|
||||||
|
};
|
||||||
|
|
||||||
|
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, locations] = await Promise.all([
|
||||||
|
getBreweries(limit, offset),
|
||||||
|
getBreweryLocations(),
|
||||||
|
]);
|
||||||
|
return { breweries, locations, limit, offset };
|
||||||
|
};
|
||||||
|
|
||||||
|
const BreweryMap = lazy(() => import('../components/directory/BreweryMap'));
|
||||||
|
|
||||||
|
const toPins = (breweries: SimplifiedBrewery[]): BreweryMapPin[] =>
|
||||||
|
breweries
|
||||||
|
.filter((brewery) => brewery.location?.coordinates)
|
||||||
|
.map((brewery) => ({
|
||||||
|
id: brewery.breweryPostId,
|
||||||
|
name: brewery.breweryName,
|
||||||
|
latitude: brewery.location!.coordinates!.latitude,
|
||||||
|
longitude: brewery.location!.coordinates!.longitude,
|
||||||
|
location: brewery.location,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const Breweries = ({ loaderData }: Route.ComponentProps) => {
|
||||||
|
const [allBreweries, setAllBreweries] = useState(loaderData.breweries);
|
||||||
|
const [offset, setOffset] = useState(loaderData.breweries.length);
|
||||||
|
const [hasMore, setHasMore] = useState(loaderData.breweries.length === loaderData.limit);
|
||||||
|
const [loadedPage, setLoadedPage] = useState<typeof loaderData | undefined>(undefined);
|
||||||
|
const fetcher = useFetcher<typeof loader>();
|
||||||
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
if (fetcher.data && fetcher.data !== loadedPage) {
|
||||||
|
setLoadedPage(fetcher.data);
|
||||||
|
setAllBreweries((prev) => [...prev, ...fetcher.data!.breweries]);
|
||||||
|
setOffset((prev) => prev + fetcher.data!.breweries.length);
|
||||||
|
setHasMore(fetcher.data.breweries.length === PAGE_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
if (!sentinel || !hasMore) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
if (entries[0]?.isIntersecting && fetcher.state === 'idle') {
|
||||||
|
fetcher.load(`/breweries/directory?limit=${PAGE_SIZE}&offset=${offset}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [hasMore, offset, fetcher]);
|
||||||
|
|
||||||
|
const pins = toPins(loaderData.locations);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-base-200">
|
||||||
|
<div className="container mx-auto p-14">
|
||||||
|
<Link
|
||||||
|
to="/breweries"
|
||||||
|
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Breweries
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-4xl font-bold mb-4">Full brewery directory</h1>
|
||||||
|
<p className="text-base-content/70 mb-6">
|
||||||
|
All of our partner breweries, newest first.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
|
||||||
|
{() => (
|
||||||
|
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
|
||||||
|
<BreweryMap breweries={pins} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
|
||||||
|
{allBreweries.length === 0 ? (
|
||||||
|
<p className="text-center text-base-content/60 mt-12">
|
||||||
|
No breweries found yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
|
||||||
|
{allBreweries.map((brewery) => (
|
||||||
|
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
|
||||||
|
))}
|
||||||
|
{fetcher.state !== 'idle' &&
|
||||||
|
Array.from({ length: PAGE_SIZE }).map((_, index) => (
|
||||||
|
<div key={index} className="skeleton h-32 w-full rounded-box" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMore && <div ref={sentinelRef} className="h-1" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Breweries;
|
||||||
|
|
||||||
|
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
|
||||||
|
<RouteErrorState error={error} />
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { data } from 'react-router';
|
||||||
|
import { getBreweryLocationsNearby } from '../../breweries.server';
|
||||||
|
import type { Route } from './+types/breweries-nearby';
|
||||||
|
|
||||||
|
export const loader = async ({ request }: Route.LoaderArgs) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const latitude = Number(url.searchParams.get('latitude'));
|
||||||
|
const longitude = Number(url.searchParams.get('longitude'));
|
||||||
|
const rangeInMetres = Number(url.searchParams.get('rangeInMetres'));
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isFinite(latitude) ||
|
||||||
|
!Number.isFinite(longitude) ||
|
||||||
|
!Number.isFinite(rangeInMetres)
|
||||||
|
) {
|
||||||
|
throw data('latitude, longitude, and rangeInMetres are required.', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const breweries = await getBreweryLocationsNearby(latitude, longitude, rangeInMetres);
|
||||||
|
return { breweries };
|
||||||
|
};
|
||||||
9
web/frontend/app/features/breweries/utils/distance.ts
Normal file
9
web/frontend/app/features/breweries/utils/distance.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type DistanceUnit = 'km' | 'mi';
|
||||||
|
|
||||||
|
const METRES_PER_MILE = 1609.344;
|
||||||
|
|
||||||
|
export const formatDistance = (metres: number, unit: DistanceUnit = 'km'): string => {
|
||||||
|
const value = unit === 'mi' ? metres / METRES_PER_MILE : metres / 1000;
|
||||||
|
const rounded = value < 10 ? value.toFixed(1) : String(Math.round(value));
|
||||||
|
return `${rounded} ${unit}`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// see BREWERY_HANDOFF.md — filler data pending backend support for
|
||||||
|
// beers-by-brewery, likes, ratings, comments, and brewery founded/type/website fields.
|
||||||
|
|
||||||
|
export interface FillerBeer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
style: string;
|
||||||
|
abv: number;
|
||||||
|
rating: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILLER_BEERS: FillerBeer[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Switchyard IPA',
|
||||||
|
description: 'Flagship west coast IPA, pine and grapefruit',
|
||||||
|
style: 'IPA',
|
||||||
|
abv: 6.8,
|
||||||
|
rating: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Rail Spur DIPA',
|
||||||
|
description: 'Double dry-hopped, citra and mosaic',
|
||||||
|
style: 'IPA',
|
||||||
|
abv: 8.2,
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Kettle Row Hazy',
|
||||||
|
description: 'Soft, juicy, low bitterness',
|
||||||
|
style: 'IPA',
|
||||||
|
abv: 6.4,
|
||||||
|
rating: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Flanders No. 3',
|
||||||
|
description: 'Barrel-aged flanders red, 18 months in oak',
|
||||||
|
style: 'Sour',
|
||||||
|
abv: 6.0,
|
||||||
|
rating: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: 'Blackberry Cassis Gose',
|
||||||
|
description: 'Collab with Cassis & Coal, lightly salted',
|
||||||
|
style: 'Sour',
|
||||||
|
abv: 4.6,
|
||||||
|
rating: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
name: 'Foudre Kriek',
|
||||||
|
description: 'Whole cherries, second-use barrels',
|
||||||
|
style: 'Sour',
|
||||||
|
abv: 5.4,
|
||||||
|
rating: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '7',
|
||||||
|
name: 'Yard Lamp Lager',
|
||||||
|
description: 'Crisp helles-style lager',
|
||||||
|
style: 'Lager',
|
||||||
|
abv: 4.9,
|
||||||
|
rating: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '8',
|
||||||
|
name: 'Night Shift Stout',
|
||||||
|
description: 'Export stout with roasted barley',
|
||||||
|
style: 'Stout',
|
||||||
|
abv: 7.1,
|
||||||
|
rating: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
|
name: 'Hefeweizen Classic',
|
||||||
|
description: 'Banana and clove, open fermentation',
|
||||||
|
style: 'Weizen',
|
||||||
|
abv: 5.2,
|
||||||
|
rating: 3,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface FillerComment {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
initials: string;
|
||||||
|
rating: number;
|
||||||
|
time: string;
|
||||||
|
text: string;
|
||||||
|
likes: number;
|
||||||
|
liked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILLER_COMMENTS: 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. Staff walked us through the whole sour program on the Saturday tour.',
|
||||||
|
likes: 12,
|
||||||
|
liked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
user: 'stoutfan_88',
|
||||||
|
initials: 'SB',
|
||||||
|
rating: 4,
|
||||||
|
time: '1 week ago',
|
||||||
|
text: 'Great IPAs, solid taproom. Gets crowded after 7pm on weekends — go early if you want a seat in the barrel room.',
|
||||||
|
likes: 5,
|
||||||
|
liked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
user: 'ale_annie',
|
||||||
|
initials: 'AL',
|
||||||
|
rating: 4,
|
||||||
|
time: '3 weeks ago',
|
||||||
|
text: 'Their cassis gose collab with Cassis & Coal is still on tap. Slightly pricey flights, but generous pours.',
|
||||||
|
likes: 3,
|
||||||
|
liked: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface FillerBreweryMeta {
|
||||||
|
foundedYear: number;
|
||||||
|
type: string;
|
||||||
|
website: string;
|
||||||
|
likeCount: number;
|
||||||
|
avgRating: number;
|
||||||
|
ratingsCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FILLER_BREWERY_META: FillerBreweryMeta = {
|
||||||
|
foundedYear: 2014,
|
||||||
|
type: 'Microbrewery',
|
||||||
|
website: 'example.com',
|
||||||
|
likeCount: 127,
|
||||||
|
avgRating: 4,
|
||||||
|
ratingsCount: 76,
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { BreweryLocation } from '../breweries.server';
|
||||||
|
|
||||||
|
export const formatBreweryAddress = (location: BreweryLocation): string => {
|
||||||
|
const streetLine = location.addressLine2
|
||||||
|
? `${location.addressLine1}, ${location.addressLine2}`
|
||||||
|
: location.addressLine1;
|
||||||
|
|
||||||
|
return `${streetLine}, ${location.cityName}, ${location.stateProvinceCode} ${location.postalCode}, ${location.countryName}`;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import { expect, within } from 'storybook/test';
|
import { expect, within } from 'storybook/test';
|
||||||
import { biergartenThemes } from '../app/features/theme/themes';
|
import { biergartenThemes } from './themes';
|
||||||
|
|
||||||
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
|
const themesDescription = `Palette reference for all Biergarten themes. Each panel shows the main semantic color pairs, status tokens, and custom content tokens so you can catch contrast issues, pairing mistakes, and mood drift before they show up in real components.`;
|
||||||
|
|
||||||
20
web/frontend/app/hooks/useDebouncedValue.ts
Normal file
20
web/frontend/app/hooks/useDebouncedValue.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a value that only updates after `delayMs` has passed without `value` changing.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const debouncedQuery = useDebouncedValue(query, 300);
|
||||||
|
*/
|
||||||
|
const useDebouncedValue = <T>(value: T, delayMs: number): T => {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timeout = setTimeout(() => setDebounced(value), delayMs);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [value, delayMs]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useDebouncedValue;
|
||||||
22
web/frontend/app/hooks/useMediaQuery.ts
Normal file
22
web/frontend/app/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
const subscribe = (query: string) => (callback: () => void) => {
|
||||||
|
const media = window.matchMedia(query);
|
||||||
|
media.addEventListener('change', callback);
|
||||||
|
return () => media.removeEventListener('change', callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks whether a CSS media query currently matches the viewport, updating on change.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const isSmallScreen = useMediaQuery('(max-width: 640px)');
|
||||||
|
*/
|
||||||
|
const useMediaQuery = (query: `(${string})`) =>
|
||||||
|
useSyncExternalStore(
|
||||||
|
subscribe(query),
|
||||||
|
() => window.matchMedia(query).matches,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
export default useMediaQuery;
|
||||||
@@ -11,6 +11,8 @@ export default [
|
|||||||
route('confirm', 'features/auth/routes/confirm.tsx'),
|
route('confirm', 'features/auth/routes/confirm.tsx'),
|
||||||
route('beers', 'features/catalog/routes/beers.tsx'),
|
route('beers', 'features/catalog/routes/beers.tsx'),
|
||||||
route('breweries', 'features/breweries/routes/breweries.tsx'),
|
route('breweries', 'features/breweries/routes/breweries.tsx'),
|
||||||
|
route('breweries/directory', 'features/breweries/routes/brewery-directory.tsx'),
|
||||||
|
route('breweries/nearby', 'features/breweries/routes/resources/breweries-nearby.ts'),
|
||||||
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
route('breweries/:id', 'features/breweries/routes/brewery-detail.tsx'),
|
||||||
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
route('beer-styles', 'features/catalog/routes/beer-styles.tsx'),
|
||||||
] satisfies RouteConfig;
|
] satisfies RouteConfig;
|
||||||
|
|||||||
51
web/frontend/package-lock.json
generated
51
web/frontend/package-lock.json
generated
@@ -15,10 +15,12 @@
|
|||||||
"@react-router/node": "^8.3.0",
|
"@react-router/node": "^8.3.0",
|
||||||
"iconoir-react": "^7.11.1",
|
"iconoir-react": "^7.11.1",
|
||||||
"isbot": "^5.2.1",
|
"isbot": "^5.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.86.0",
|
"react-hook-form": "^7.86.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-leaflet": "^5.0.0-rc.2",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -33,6 +35,7 @@
|
|||||||
"@storybook/react-vite": "^10.2.19",
|
"@storybook/react-vite": "^10.2.19",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
@@ -2301,6 +2304,17 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-router/dev": {
|
"node_modules/@react-router/dev": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-8.3.0.tgz",
|
||||||
@@ -3558,6 +3572,13 @@
|
|||||||
"@types/send": "*"
|
"@types/send": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/http-errors": {
|
"node_modules/@types/http-errors": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
@@ -3572,6 +3593,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/leaflet": {
|
||||||
|
"version": "1.9.22",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||||
|
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mdx": {
|
"node_modules/@types/mdx": {
|
||||||
"version": "2.0.14",
|
"version": "2.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz",
|
||||||
@@ -7685,6 +7716,12 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -9061,6 +9098,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0-rc.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0-rc.2.tgz",
|
||||||
|
"integrity": "sha512-1xQGYG9mEIW+nfkQhqgHImwUuB1UDlnzYFSzv6PrBFDBeYrFmv0BbpwpNAFdJg/UQ2yz5UZSL7ZwlUxjwb8MZw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0-rc.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
|
|||||||
@@ -25,10 +25,12 @@
|
|||||||
"@react-router/node": "^8.3.0",
|
"@react-router/node": "^8.3.0",
|
||||||
"iconoir-react": "^7.11.1",
|
"iconoir-react": "^7.11.1",
|
||||||
"isbot": "^5.2.1",
|
"isbot": "^5.2.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.86.0",
|
"react-hook-form": "^7.86.0",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-leaflet": "^5.0.0-rc.2",
|
||||||
"react-router": "^8.3.0",
|
"react-router": "^8.3.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -43,6 +45,7 @@
|
|||||||
"@storybook/react-vite": "^10.2.19",
|
"@storybook/react-vite": "^10.2.19",
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^25.5.2",
|
"@types/node": "^25.5.2",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.4",
|
"@types/react-dom": "^19.2.4",
|
||||||
|
|||||||
Reference in New Issue
Block a user