mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
Add sidebar cards and shared star-rating component for brewery detail page
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user