Add brewery discovery & detail pages with location-based search (#253)

This commit is contained in:
Aaron Po
2026-08-29 00:44:33 -04:00
committed by GitHub
parent 92a30b11db
commit a580314583
90 changed files with 3874 additions and 187 deletions

View File

@@ -1,38 +1,101 @@
import { useState } from 'react';
import { Link } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import BreweryCard from '../components/BreweryCard';
import FeaturedBreweryCard from '../components/overview/FeaturedBreweryCard';
import NearbyBreweriesSection from '../components/overview/nearby/NearbyBreweriesSection';
import RecentBreweries from '../components/overview/RecentBreweries';
import { getBreweries } from '../breweries.server';
import type { Route } from './+types/breweries';
// No count endpoint exists yet - see BREWERY_HANDOFF.md.
const FILLER_TOTAL_BREWERY_COUNT = 128;
export const meta = ({}: Route.MetaArgs) => {
return [{ title: 'Breweries | The Biergarten App' }];
};
export const loader = async () => {
const breweries = await getBreweries();
return { breweries };
// "Featured" is just the most recently posted brewery for now - see BREWERY_HANDOFF.md.
const [featured, ...recent] = await getBreweries(4, 0);
return { featured: featured ?? null, recent };
};
const Breweries = ({ loaderData }: Route.ComponentProps) => {
const { breweries } = loaderData;
const { featured, recent } = loaderData;
const [selectedId, setSelectedId] = useState<string | null>(null);
const featuredPin = featured?.location?.coordinates
? {
id: featured.breweryPostId,
name: featured.breweryName,
latitude: featured.location.coordinates.latitude,
longitude: featured.location.coordinates.longitude,
}
: null;
const fallbackCenter = featured?.location?.coordinates
? {
latitude: featured.location.coordinates.latitude,
longitude: featured.location.coordinates.longitude,
label: featured.location.cityName,
}
: null;
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-4">
<h1 className="text-4xl font-bold mb-4">Breweries</h1>
<p className="text-base-content/70 mb-6">Discover our partner breweries.</p>
{breweries.length === 0 ? (
<div className="alert alert-info alert-soft">
<span>No breweries have been posted yet.</span>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{breweries.map((brewery) => (
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
))}
</div>
)}
<div className="min-h-screen bg-base-200 text-base-content pb-16">
<div className="max-w-7xl mx-auto px-5 pt-10">
<h1 className="font-serif text-5xl leading-tight mb-2">Breweries</h1>
<p className="text-lg text-base-content/60 max-w-xl m-0">
Discover our partner breweries — start with this week&apos;s feature, then see
what&apos;s pouring near you.
</p>
</div>
{featured && (
<section className="max-w-7xl mx-auto px-5 pt-8">
<div className="flex items-baseline gap-3 mb-3.5">
<h2 className="font-serif text-2xl m-0">Featured brewery</h2>
<span className="badge badge-primary badge-sm uppercase tracking-widest font-bold">
This week
</span>
</div>
<FeaturedBreweryCard
brewery={featured}
onShowOnMap={() => {
setSelectedId(featured.breweryPostId);
document
.getElementById('breweries-near-you')
?.scrollIntoView({ behavior: 'smooth' });
}}
/>
</section>
)}
<NearbyBreweriesSection
featured={featuredPin}
fallbackCenter={fallbackCenter}
selectedId={selectedId}
onSelect={setSelectedId}
/>
<RecentBreweries breweries={recent} />
<section className="max-w-7xl mx-auto px-5 pt-11">
<div className="bg-[var(--color-highlight)] text-[var(--color-highlight-content)] rounded-box p-7 flex items-center justify-between gap-6 flex-wrap">
<div>
<h2 className="font-serif text-2xl mb-1">
Looking for something specific?
</h2>
<p className="m-0">
All {FILLER_TOTAL_BREWERY_COUNT} partner breweries, filterable by
country, region, and beer style.
</p>
</div>
<Link to="/breweries/directory" className="btn btn-primary">
Browse the full directory
</Link>
</div>
</section>
</div>
);
};

View File

@@ -1,55 +1,132 @@
import { useState } from 'react';
import { data, Link } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import { getOptionalAuth } from '../../auth/auth.server';
import { getBreweryById } from '../breweries.server';
import BreweryHeaderCard from '../components/detail/BreweryHeaderCard';
import YourRatingCard from '../components/detail/YourRatingCard';
import BeerListCard from '../components/detail/BeerListCard';
import CommentsCard from '../components/detail/comments/CommentsCard';
import LocationCard from '../components/detail/LocationCard';
import BreweryDetailsCard from '../components/detail/BreweryDetailsCard';
import BeerStyleBreakdownCard from '../components/detail/BeerStyleBreakdownCard';
import CommunityStatsCard from '../components/detail/CommunityStatsCard';
import { FILLER_BEERS, FILLER_BREWERY_META, FILLER_COMMENTS } from '../utils/filler-brewery-detail';
import type { Route } from './+types/brewery-detail';
export const loader = async ({ params }: Route.LoaderArgs) => {
const brewery = await getBreweryById(params.id);
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const [brewery, auth] = await Promise.all([
getBreweryById(params.id),
getOptionalAuth(request),
]);
if (!brewery) {
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 { 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 (
<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
to="/breweries"
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
>
&larr; Back to Breweries
&larr; Back to breweries
</Link>
<div className="card bg-base-100 shadow">
<div className="card-body">
<h1 className="card-title text-3xl">{brewery.breweryName}</h1>
<p className="text-base-content/70 whitespace-pre-line">
{brewery.description}
</p>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_19rem] gap-5 items-start">
<div className="flex flex-col gap-5">
<BreweryHeaderCard
brewery={brewery}
foundedYear={FILLER_BREWERY_META.foundedYear}
liked={liked}
likeCount={likeCount}
avgRating={FILLER_BREWERY_META.avgRating}
ratingsCount={ratingsCount}
onToggleLike={handleToggleLike}
/>
<YourRatingCard value={yourRating} onChange={handleRatingChange} />
<BeerListCard beers={FILLER_BEERS} />
<CommentsCard
comments={comments}
loggedIn={loggedIn}
currentUserInitials={username ? initials(username) : 'YO'}
onAddComment={handleAddComment}
onToggleCommentLike={handleToggleCommentLike}
/>
</div>
<div className="flex flex-col gap-5">
{brewery.location && (
<div className="bg-base-200 rounded-box p-4 mt-4">
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-2">
Location
</p>
<p>{brewery.location.addressLine1}</p>
{brewery.location.addressLine2 && (
<p>{brewery.location.addressLine2}</p>
)}
<p>{brewery.location.postalCode}</p>
</div>
<LocationCard
breweryName={brewery.breweryName}
location={brewery.location}
website={FILLER_BREWERY_META.website}
/>
)}
<p className="text-xs text-base-content/40 mt-4">
Posted {new Date(brewery.createdAt).toLocaleDateString()}
{brewery.updatedAt &&
` · Updated ${new Date(brewery.updatedAt).toLocaleDateString()}`}
</p>
<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>

View File

@@ -0,0 +1,121 @@
import { Link } from 'react-router';
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
import ClientOnly from '../../../components/ClientOnly';
import BreweryCard from '../components/directory/BreweryCard';
import { getBreweries, getBreweryLocations, type SimplifiedBrewery } from '../breweries.server';
import type { Route } from './+types/brewery-directory';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { useFetcher } from 'react-router';
import type { BreweryMapPin } from '../components/directory/BreweryMap';
const PAGE_SIZE = 12;
const MAP_PLACEHOLDER_CLASS = 'skeleton h-[28rem] md:h-[34rem] w-full rounded-box';
export const meta = ({}: Route.MetaArgs) => {
return [{ title: 'Brewery Directory | The Biergarten App' }];
};
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const limit = Number(url.searchParams.get('limit')) || PAGE_SIZE;
const offset = Number(url.searchParams.get('offset')) || 0;
const [breweries, locations] = await Promise.all([
getBreweries(limit, offset),
getBreweryLocations(),
]);
return { breweries, locations, limit, offset };
};
const BreweryMap = lazy(() => import('../components/directory/BreweryMap'));
const toPins = (breweries: SimplifiedBrewery[]): BreweryMapPin[] =>
breweries
.filter((brewery) => brewery.location?.coordinates)
.map((brewery) => ({
id: brewery.breweryPostId,
name: brewery.breweryName,
latitude: brewery.location!.coordinates!.latitude,
longitude: brewery.location!.coordinates!.longitude,
location: brewery.location,
}));
const Breweries = ({ loaderData }: Route.ComponentProps) => {
const [allBreweries, setAllBreweries] = useState(loaderData.breweries);
const [offset, setOffset] = useState(loaderData.breweries.length);
const [hasMore, setHasMore] = useState(loaderData.breweries.length === loaderData.limit);
const [loadedPage, setLoadedPage] = useState<typeof loaderData | undefined>(undefined);
const fetcher = useFetcher<typeof loader>();
const sentinelRef = useRef<HTMLDivElement>(null);
if (fetcher.data && fetcher.data !== loadedPage) {
setLoadedPage(fetcher.data);
setAllBreweries((prev) => [...prev, ...fetcher.data!.breweries]);
setOffset((prev) => prev + fetcher.data!.breweries.length);
setHasMore(fetcher.data.breweries.length === PAGE_SIZE);
}
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel || !hasMore) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && fetcher.state === 'idle') {
fetcher.load(`/breweries/directory?limit=${PAGE_SIZE}&offset=${offset}`);
}
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, offset, fetcher]);
const pins = toPins(loaderData.locations);
return (
<div className="min-h-screen bg-base-200">
<div className="container mx-auto p-14">
<Link
to="/breweries"
className="link link-hover text-sm text-base-content/60 mb-4 inline-block"
>
&larr; Back to Breweries
</Link>
<h1 className="text-4xl font-bold mb-4">Full brewery directory</h1>
<p className="text-base-content/70 mb-6">
All of our partner breweries, newest first.
</p>
<ClientOnly fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
{() => (
<Suspense fallback={<div className={MAP_PLACEHOLDER_CLASS} />}>
<BreweryMap breweries={pins} />
</Suspense>
)}
</ClientOnly>
{allBreweries.length === 0 ? (
<p className="text-center text-base-content/60 mt-12">
No breweries found yet.
</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
{allBreweries.map((brewery) => (
<BreweryCard key={brewery.breweryPostId} brewery={brewery} />
))}
{fetcher.state !== 'idle' &&
Array.from({ length: PAGE_SIZE }).map((_, index) => (
<div key={index} className="skeleton h-32 w-full rounded-box" />
))}
</div>
)}
{hasMore && <div ref={sentinelRef} className="h-1" />}
</div>
</div>
);
};
export default Breweries;
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
<RouteErrorState error={error} />
);

View File

@@ -0,0 +1,21 @@
import { data } from 'react-router';
import { getBreweryLocationsNearby } from '../../breweries.server';
import type { Route } from './+types/breweries-nearby';
export const loader = async ({ request }: Route.LoaderArgs) => {
const url = new URL(request.url);
const latitude = Number(url.searchParams.get('latitude'));
const longitude = Number(url.searchParams.get('longitude'));
const rangeInMetres = Number(url.searchParams.get('rangeInMetres'));
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
!Number.isFinite(rangeInMetres)
) {
throw data('latitude, longitude, and rangeInMetres are required.', { status: 400 });
}
const breweries = await getBreweryLocationsNearby(latitude, longitude, rangeInMetres);
return { breweries };
};