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