From 83863b0bd8c7e1a9e59a194325dea730f16a446b Mon Sep 17 00:00:00 2001 From: Aaron Po Date: Fri, 28 Aug 2026 14:19:07 -0400 Subject: [PATCH] Wire up the brewery detail route with all new cards, and add the data-requirements handoff doc --- .../breweries/BREWERY_DETAIL_HANDOFF.md | 55 ++++++++ .../breweries/components/LocationCard.tsx | 7 +- .../breweries/routes/brewery-detail.tsx | 130 ++++++++++++++---- 3 files changed, 166 insertions(+), 26 deletions(-) create mode 100644 web/frontend/app/features/breweries/BREWERY_DETAIL_HANDOFF.md diff --git a/web/frontend/app/features/breweries/BREWERY_DETAIL_HANDOFF.md b/web/frontend/app/features/breweries/BREWERY_DETAIL_HANDOFF.md new file mode 100644 index 00000000..87fbec95 --- /dev/null +++ b/web/frontend/app/features/breweries/BREWERY_DETAIL_HANDOFF.md @@ -0,0 +1,55 @@ +# Brewery detail — data requirements + +`/breweries/:id` (`app/features/breweries/routes/brewery-detail.tsx`) is built and +live. This document inventories what's real vs. filler, following the same +convention as `BREWERY_INDEX_HANDOFF.md`. + +## Status legend + +- ✅ **Available** — real data, already wired up. +- ❌ **Missing** — no backend support at all; page currently shows filler or + local-only client state. + +## Header, location, dates + +| Field | Status | Current 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 | Current source | Gap | +| --------------------------- | ------ | --------------------- | -------------------------------------------------------------------------------------- | +| Beers belonging to a brewery | ❌ | `FILLER_BEERS` | `Features.Beers` is an empty stub project — no query/controller exposes `BeerPost` at all, despite the entity already having a `BrewedById` FK. | +| Beer style breakdown | ❌ | Derived client-side from `FILLER_BEERS` | Blocked on the same gap; would otherwise be an aggregate query over a brewery's beers. | + +## Likes, ratings, comments + +| Field | Status | Current source | Gap | +| ------------------------------ | ------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | +| Brewery like/unlike + count | ❌ | Local component state only | No like concept exists anywhere in the schema. Needs a new join table (composite `BreweryPostId`/`UserAccountId`, similar shape to `UserFollow`). | +| Your rating (1–5) + average | ❌ | Local component state only | No brewery rating table/entity/endpoint exists. | +| Comments (list + post) | ❌ | `FILLER_COMMENTS`, local state | No `BreweryPostComment` table exists. `BeerPostComment` is a close schema precedent (comment + 1–5 `CHECK`-constrained rating + FKs) but is itself unused by any code and targets `BeerPost`, not `BreweryPost`. | +| Per-comment like toggle | ❌ | Local component state only | No "like a comment" concept exists anywhere in the schema — would need a new join table alongside a new `BreweryPostComment` table. | + +## Summary of backend work to close the gaps + +1. Add `FoundedAt`, `Type`, `Website` columns to `BreweryPost` (+ `BreweryDto`). +2. Stand up `Features.Beers`: a query (and controller route) returning a + brewery's beers, following the `Features.Breweries` vertical-slice pattern. +3. New `BreweryPostLike` join table + MediatR command/query + controller + endpoints (toggle, read count/whether current user liked) — public GET, + JWT-required toggle, mirroring `BreweryController`'s + `[AllowAnonymous]`-on-reads pattern. +4. New `BreweryPostRating` table (1–5, `CHECK`-constrained like + `BeerPostComment.Rating`) + aggregate query for average/count. +5. New `BreweryPostComment` table (mirroring `BeerPostComment.sql`, FK'd to + `BreweryPost`) + a new `BreweryPostCommentLike` join table for per-comment + likes, plus the command/query/controller layer for both. + +Once these land, swap `FILLER_BEERS` / `FILLER_COMMENTS` / `FILLER_BREWERY_META` +(`utils/filler-brewery-detail.ts`) and the local `useState` in +`brewery-detail.tsx` for loader data and `authorizedRequest`-based mutations +(see `auth.server.ts`). diff --git a/web/frontend/app/features/breweries/components/LocationCard.tsx b/web/frontend/app/features/breweries/components/LocationCard.tsx index 25cc3dae..04626929 100644 --- a/web/frontend/app/features/breweries/components/LocationCard.tsx +++ b/web/frontend/app/features/breweries/components/LocationCard.tsx @@ -31,7 +31,12 @@ const LocationCard = ({ breweryName, location, website }: LocationCardProps) => )}

{formatBreweryAddress(location)}

- + {website}

diff --git a/web/frontend/app/features/breweries/routes/brewery-detail.tsx b/web/frontend/app/features/breweries/routes/brewery-detail.tsx index 466458a6..417fa243 100644 --- a/web/frontend/app/features/breweries/routes/brewery-detail.tsx +++ b/web/frontend/app/features/breweries/routes/brewery-detail.tsx @@ -1,52 +1,132 @@ +import { useState } from 'react'; import { data, Link } from 'react-router'; import RouteErrorState from '../../../components/ui/error/RouteErrorState'; +import { getOptionalAuth } from '../../auth/auth.server'; import { getBreweryById } from '../breweries.server'; -import { formatBreweryAddress } from '../utils/format-address'; +import BreweryHeaderCard from '../components/BreweryHeaderCard'; +import YourRatingCard from '../components/YourRatingCard'; +import BeerListCard from '../components/BeerListCard'; +import CommentsCard from '../components/CommentsCard'; +import LocationCard from '../components/LocationCard'; +import BreweryDetailsCard from '../components/BreweryDetailsCard'; +import BeerStyleBreakdownCard from '../components/BeerStyleBreakdownCard'; +import CommunityStatsCard from '../components/CommunityStatsCard'; +import { FILLER_BEERS, FILLER_BREWERY_META, FILLER_COMMENTS } from '../utils/filler-brewery-detail'; import type { Route } from './+types/brewery-detail'; -export const loader = async ({ params }: Route.LoaderArgs) => { - const brewery = await getBreweryById(params.id); +export const loader = async ({ request, params }: Route.LoaderArgs) => { + const [brewery, auth] = await Promise.all([ + getBreweryById(params.id), + getOptionalAuth(request), + ]); + if (!brewery) { 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 { 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 (
-
+
- ← Back to Breweries + ← Back to breweries -
-
-

{brewery.breweryName}

-

- {brewery.description} -

+
+
+ + + + +
+
{brewery.location && ( -
-

- Location -

-

{formatBreweryAddress(brewery.location)}

-
+ )} - -

- Posted {new Date(brewery.createdAt).toLocaleDateString()} - {brewery.updatedAt && - ` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`} -

+ + +