mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add brewery discovery & detail pages with location-based search (#253)
This commit is contained in:
@@ -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's feature, then see
|
||||
what'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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← Back to Breweries
|
||||
← 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>
|
||||
|
||||
121
web/frontend/app/features/breweries/routes/brewery-directory.tsx
Normal file
121
web/frontend/app/features/breweries/routes/brewery-directory.tsx
Normal 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"
|
||||
>
|
||||
← 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} />
|
||||
);
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user