diff --git a/web/frontend/app/features/breweries/components/BeerStyleBreakdownCard.tsx b/web/frontend/app/features/breweries/components/BeerStyleBreakdownCard.tsx new file mode 100644 index 00000000..c1f7c6b0 --- /dev/null +++ b/web/frontend/app/features/breweries/components/BeerStyleBreakdownCard.tsx @@ -0,0 +1,33 @@ +import SidebarCard from './SidebarCard'; +import type { FillerBeer } from '../utils/filler-brewery-detail'; + +interface BeerStyleBreakdownCardProps { + beers: FillerBeer[]; +} + +const BeerStyleBreakdownCard = ({ beers }: BeerStyleBreakdownCardProps) => { + const counts = new Map(); + beers.forEach((beer) => counts.set(beer.style, (counts.get(beer.style) ?? 0) + 1)); + const maxCount = Math.max(...counts.values()); + const styles = [...counts.entries()].sort((a, b) => b[1] - a[1]); + + return ( + + {styles.map(([style, count]) => ( +
+
+ {style} + {count} +
+ +
+ ))} +
+ ); +}; + +export default BeerStyleBreakdownCard; diff --git a/web/frontend/app/features/breweries/components/BreweryDetailsCard.tsx b/web/frontend/app/features/breweries/components/BreweryDetailsCard.tsx new file mode 100644 index 00000000..8a58d4cd --- /dev/null +++ b/web/frontend/app/features/breweries/components/BreweryDetailsCard.tsx @@ -0,0 +1,24 @@ +import SidebarCard from './SidebarCard'; + +interface BreweryDetailsCardProps { + foundedYear: number; + type: string; + beerCount: number; +} + +const DetailRow = ({ label, value }: { label: string; value: string | number }) => ( +
+ {label} + {value} +
+); + +const BreweryDetailsCard = ({ foundedYear, type, beerCount }: BreweryDetailsCardProps) => ( + + + + + +); + +export default BreweryDetailsCard; diff --git a/web/frontend/app/features/breweries/components/BreweryLocationMap.tsx b/web/frontend/app/features/breweries/components/BreweryLocationMap.tsx new file mode 100644 index 00000000..2afa317a --- /dev/null +++ b/web/frontend/app/features/breweries/components/BreweryLocationMap.tsx @@ -0,0 +1,41 @@ +import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; +import L, { type LatLngTuple } from 'leaflet'; +import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png'; +import iconUrl from 'leaflet/dist/images/marker-icon.png'; +import shadowUrl from 'leaflet/dist/images/marker-shadow.png'; +import 'leaflet/dist/leaflet.css'; + +delete (L.Icon.Default.prototype as unknown as { _getIconUrl?: unknown })._getIconUrl; +L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl }); + +interface BreweryLocationMapProps { + latitude: number; + longitude: number; + label: string; +} + +const ZOOM = 15; + +const BreweryLocationMap = ({ latitude, longitude, label }: BreweryLocationMapProps) => { + const position: LatLngTuple = [latitude, longitude]; + + return ( + + + + {label} + + + ); +}; + +export default BreweryLocationMap; diff --git a/web/frontend/app/features/breweries/components/CommunityStatsCard.tsx b/web/frontend/app/features/breweries/components/CommunityStatsCard.tsx new file mode 100644 index 00000000..1774d10c --- /dev/null +++ b/web/frontend/app/features/breweries/components/CommunityStatsCard.tsx @@ -0,0 +1,24 @@ +import SidebarCard from './SidebarCard'; + +interface CommunityStatsCardProps { + likeCount: number; + ratingsCount: number; + commentCount: number; +} + +const StatRow = ({ label, value }: { label: string; value: number }) => ( +
+ {label} + {value} +
+); + +const CommunityStatsCard = ({ likeCount, ratingsCount, commentCount }: CommunityStatsCardProps) => ( + + + + + +); + +export default CommunityStatsCard; diff --git a/web/frontend/app/features/breweries/components/LocationCard.tsx b/web/frontend/app/features/breweries/components/LocationCard.tsx new file mode 100644 index 00000000..25cc3dae --- /dev/null +++ b/web/frontend/app/features/breweries/components/LocationCard.tsx @@ -0,0 +1,41 @@ +import { lazy, Suspense } from 'react'; +import ClientOnly from '../../../components/ClientOnly'; +import SidebarCard from './SidebarCard'; +import { formatBreweryAddress } from '../utils/format-address'; +import type { BreweryLocation } from '../breweries.server'; + +const BreweryLocationMap = lazy(() => import('./BreweryLocationMap')); + +interface LocationCardProps { + breweryName: string; + location: BreweryLocation; + website: string; +} + +const MAP_PLACEHOLDER_CLASS = 'skeleton h-45 w-full rounded-field'; + +const LocationCard = ({ breweryName, location, website }: LocationCardProps) => ( + + {location.coordinates && ( + }> + {() => ( + }> + + + )} + + )} +

{formatBreweryAddress(location)}

+

+ + {website} + +

+
+); + +export default LocationCard; diff --git a/web/frontend/app/features/breweries/components/SidebarCard.tsx b/web/frontend/app/features/breweries/components/SidebarCard.tsx new file mode 100644 index 00000000..be7e17e3 --- /dev/null +++ b/web/frontend/app/features/breweries/components/SidebarCard.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +interface SidebarCardProps { + title: string; + children: ReactNode; +} + +const SidebarCard = ({ title, children }: SidebarCardProps) => ( +
+
+

+ {title} +

+ {children} +
+
+); + +export default SidebarCard; diff --git a/web/frontend/app/features/breweries/components/StarRating.tsx b/web/frontend/app/features/breweries/components/StarRating.tsx new file mode 100644 index 00000000..edeeed38 --- /dev/null +++ b/web/frontend/app/features/breweries/components/StarRating.tsx @@ -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, 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 ( +
+ {stars.map((n) => ( +
+ ))} +
+ ); + } + + return ( +
+ onChange(0)} + aria-label="Clear rating" + /> + {stars.map((n) => ( + onChange(n)} + onClick={() => { + if (value === n) onChange(0); + }} + aria-label={`Rate ${n} of ${max}`} + /> + ))} +
+ ); +}; + +export default StarRating; diff --git a/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts b/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts new file mode 100644 index 00000000..6ab089a0 --- /dev/null +++ b/web/frontend/app/features/breweries/utils/filler-brewery-detail.ts @@ -0,0 +1,148 @@ +// see BREWERY_DETAIL_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, +};