Add sidebar cards and shared star-rating component for brewery detail page

This commit is contained in:
Aaron Po
2026-08-28 14:16:52 -04:00
parent 28148e9ec4
commit 989aa3ca52
8 changed files with 401 additions and 0 deletions

View File

@@ -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<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;

View File

@@ -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 }) => (
<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;

View File

@@ -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='&copy; <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;

View File

@@ -0,0 +1,24 @@
import SidebarCard from './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;

View File

@@ -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) => (
<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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};