mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Wire up the brewery detail route with all new cards, and add the data-requirements handoff doc
This commit is contained in:
@@ -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`).
|
||||||
@@ -31,7 +31,12 @@ const LocationCard = ({ breweryName, location, website }: LocationCardProps) =>
|
|||||||
)}
|
)}
|
||||||
<p className="text-sm leading-relaxed m-0">{formatBreweryAddress(location)}</p>
|
<p className="text-sm leading-relaxed m-0">{formatBreweryAddress(location)}</p>
|
||||||
<p className="text-sm m-0">
|
<p className="text-sm m-0">
|
||||||
<a href={`https://${website}`} className="link link-primary" target="_blank" rel="noreferrer">
|
<a
|
||||||
|
href={`https://${website}`}
|
||||||
|
className="link link-primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
{website}
|
{website}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,52 +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 { 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';
|
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>{formatBreweryAddress(brewery.location)}</p>
|
<BeerListCard beers={FILLER_BEERS} />
|
||||||
|
<CommentsCard
|
||||||
|
comments={comments}
|
||||||
|
loggedIn={loggedIn}
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user