Coffee Finder offers multiple ways to discover coffee shops: search by name, browse all nearby locations, filter by popularity, or explore trending spots. The discovery system combines real-time search with intelligent ranking algorithms.
Discovery modes
Users can switch between three discovery modes to find the perfect coffee shop:
Displays all coffee shops within an 8km radius, sorted by distance from your location. Best for:
Exploring all options in your area
Finding the closest coffee shop
Discovering new places you haven’t visited
Sort order: Nearest to farthestShows the top-rated coffee shops based on ratings and review volume. Best for:
Finding highly-rated establishments
Choosing a reliable option
Visiting community favorites
Sort order:
Highest rating first
Most reviews (tiebreaker)
Closest distance (second tiebreaker)
Highlights coffee shops using a composite scoring algorithm that considers multiple factors. Best for:
Discovering currently popular spots
Finding open coffee shops
Balancing quality with convenience
Scoring factors:
Currently open (+3 points)
Rating score (rating / 2)
Engagement (reviews / 200)
Available information (+1 per contact method)
Distance penalty (-1 per km, max -3)
Mode switching interface
Users switch discovery modes using tabs in the sidebar header:
const [ discoveryMode , setDiscoveryMode ] = useState < 'all' | 'popular' | 'trending' >( 'all' )
< Tabs
value = { discoveryMode }
onValueChange = { ( value ) => setDiscoveryMode ( value as 'all' | 'popular' | 'trending' ) }
>
< TabsList className = "grid grid-cols-3 bg-white/20" >
< TabsTrigger value = "all" > All </ TabsTrigger >
< TabsTrigger value = "popular" > Popular </ TabsTrigger >
< TabsTrigger value = "trending" > Trending </ TabsTrigger >
</ TabsList >
</ Tabs >
Changing discovery modes instantly updates both the sidebar list and map markers without re-fetching data from the server.
Text search
The search bar allows users to find specific coffee shops by name:
const [ searchQuery , setSearchQuery ] = useState ( '' )
< Input
type = "text"
placeholder = "Search for coffee shops..."
value = { searchQuery }
onChange = { ( e ) => setSearchQuery ( e . target . value ) }
onKeyDown = { handleKeyDown }
className = "pl-10 bg-white dark:bg-gray-800 border-amber-200"
/>
Search interaction flow
User types in search box (e.g., “Starbucks”)
Press Enter or click the search button
Query is sent to the API with current location
Results are filtered on the server
Map and sidebar update with matching shops
const handleSearch = () => {
if ( location . lat && location . lng ) {
fetchCoffeeShops ( location . lat , location . lng , searchQuery )
}
}
const handleKeyDown = ( e : React . KeyboardEvent ) => {
if ( e . key === 'Enter' ) {
handleSearch ()
}
}
Search is case-insensitive and supports partial matches. Searching for “cafe” will find “Cafe Aroma”, “The Cozy Cafe”, and “Artisan Cafe House”.
Popular mode algorithm
The popular mode uses a multi-criteria sorting function:
const popularShops = useMemo (() => {
return [ ... coffeeShops ]
. sort (( a , b ) => {
const ratingDiff = ( b . rating || 0 ) - ( a . rating || 0 )
if ( ratingDiff !== 0 ) {
return ratingDiff
}
const reviewsDiff = ( b . totalRatings || 0 ) - ( a . totalRatings || 0 )
if ( reviewsDiff !== 0 ) {
return reviewsDiff
}
return ( a . distance || Number .MAX_SAFE_INTEGER) - ( b . distance || Number .MAX_SAFE_INTEGER)
})
. slice ( 0 , 10 )
}, [ coffeeShops ])
Sorting logic breakdown
Step 1: Rating comparison
const ratingDiff = ( b . rating || 0 ) - ( a . rating || 0 )
if ( ratingDiff !== 0 ) {
return ratingDiff
}
Shops with higher ratings appear first. A 4.8-star shop ranks above a 4.5-star shop.
Step 2: Review count tiebreaker
const reviewsDiff = ( b . totalRatings || 0 ) - ( a . totalRatings || 0 )
if ( reviewsDiff !== 0 ) {
return reviewsDiff
}
When ratings are equal, more reviews indicate higher confidence. A shop with 500 reviews ranks above one with 50 reviews (same rating).
Step 3: Distance tiebreaker
return ( a . distance || Number .MAX_SAFE_INTEGER) - ( b . distance || Number .MAX_SAFE_INTEGER)
If both rating and review count are equal, closer shops rank higher.
Only the top 10 most popular shops are shown to maintain focus on quality options.
Trending mode algorithm
Trending mode uses a weighted scoring system:
const trendingShops = useMemo (() => {
const scoreShop = ( shop : CoffeeShop ) => {
const openScore = shop . isOpen ? 3 : 0
const ratingScore = ( shop . rating || 0 ) / 2
const engagementScore = ( shop . totalRatings || 0 ) / 200
const infoScore = ( shop . website ? 1 : 0 ) + ( shop . phone ? 1 : 0 )
const distancePenalty = Math . min (( shop . distance || 3000 ) / 1000 , 3 )
return openScore + ratingScore + engagementScore + infoScore - distancePenalty
}
return [ ... coffeeShops ]
. sort (( a , b ) => scoreShop ( b ) - scoreShop ( a ))
. slice ( 0 , 10 )
}, [ coffeeShops ])
Scoring breakdown
Factor Formula Max Points Purpose Open status shop.isOpen ? 3 : 03.0 Heavily favor currently open shops Rating rating / 22.5 Quality indicator (5-star = 2.5 points) Engagement totalRatings / 200Variable Normalize review volume (200+ reviews = 1+ points) Info completeness (website ? 1 : 0) + (phone ? 1 : 0)2.0 Reward shops with contact information Distance penalty min(distance / 1000, 3)-3.0 Prefer closer shops (1km = -1, capped at 3km)
Example calculation
Coffee Shop A:
Open: Yes (+3)
Rating: 4.6 (+2.3)
Reviews: 420 (+2.1)
Website: Yes (+1)
Phone: Yes (+1)
Distance: 1.2km (-1.2)
Total Score: 8.2
Featured sections
The sidebar displays featured shops above the main list:
const featuredPopular = popularShops [ 0 ]
const featuredTrending = trendingShops [ 0 ]
{ featuredPopular && (
< button
type = "button"
className = "w-full text-left rounded-md border border-amber-200 px-3 py-2 hover:bg-amber-50"
onClick = { () => handleShopSelect ( featuredPopular ) }
>
< p className = "text-xs text-amber-700 font-medium" > Most Popular </ p >
< p className = "text-sm font-semibold truncate" > { featuredPopular . name } </ p >
< p className = "text-xs text-muted-foreground truncate" > { featuredPopular . address } </ p >
</ button >
)}
{ featuredTrending && (
< button
type = "button"
className = "w-full text-left rounded-md border border-orange-200 px-3 py-2 hover:bg-orange-50"
onClick = { () => handleShopSelect ( featuredTrending ) }
>
< p className = "text-xs text-orange-700 font-medium" > Trending Now </ p >
< p className = "text-sm font-semibold truncate" > { featuredTrending . name } </ p >
< p className = "text-xs text-muted-foreground truncate" > { featuredTrending . address } </ p >
</ button >
)}
Displays the #1 ranked shop from popular mode
Amber color scheme
Always visible (if data exists)
Quick access to highest-rated shop
Displays the #1 ranked shop from trending algorithm
Orange color scheme
Highlights currently relevant options
Often shows open shops during business hours
Visible shops computation
The active discovery mode determines which shops appear:
const visibleShops = useMemo (() => {
if ( discoveryMode === 'popular' ) {
return popularShops
}
if ( discoveryMode === 'trending' ) {
return trendingShops
}
return coffeeShops
}, [ coffeeShops , discoveryMode , popularShops , trendingShops ])
This memoized computation:
Recalculates only when dependencies change
Updates map markers automatically
Maintains scroll position in sidebar
Preserves selected shop when possible
The visibleShops array is used for both rendering the sidebar list and generating map markers, ensuring consistency between views.
Empty states
When no coffee shops match the current filters:
{ visibleShops . length === 0 ? (
< div className = "p-8 text-center text-muted-foreground" >
< Coffee className = "w-12 h-12 mx-auto mb-4 opacity-50" />
< p > No coffee shops found nearby </ p >
< p className = "text-sm mt-2" > Try searching with different keywords </ p >
</ div >
) : (
// ... render shop list
)}
This appears when:
Search query has no matches
No coffee shops exist within 8km radius
API returns empty results
Network error occurs (with fallback)
Loading states
During search or initial load:
{ loading ? (
< div className = "p-4 space-y-4" >
{ [ 1 , 2 , 3 ]. map (( i ) => (
< div key = { i } className = "space-y-2" >
< Skeleton className = "h-5 w-3/4" />
< Skeleton className = "h-4 w-full" />
< Skeleton className = "h-4 w-1/2" />
</ div >
)) }
</ div >
) : (
// ... render results
)}
Skeleton loaders provide visual feedback during data fetching, reducing perceived loading time and preventing layout shift.
Shop selection
Clicking a shop in any discovery mode:
const handleShopSelect = ( shop : CoffeeShop ) => {
setSelectedShop ( shop )
panToLocation ( shop . location . lat , shop . location . lng , 17 )
}
Highlights the shop in the sidebar
Pans the map to the shop’s location
Zooms to level 17 for detailed view
Opens marker popup (automatic)
Maintains selection when switching modes
All discovery mode computations use useMemo:
const popularShops = useMemo (() => { /* ... */ }, [ coffeeShops ])
const trendingShops = useMemo (() => { /* ... */ }, [ coffeeShops ])
const visibleShops = useMemo (() => { /* ... */ }, [ coffeeShops , discoveryMode , popularShops , trendingShops ])
This prevents:
Re-sorting on every render
Unnecessary map marker recreation
Layout thrashing from rapid state changes
Degraded performance with large result sets
With typical result sets of 60 coffee shops, the sorting and scoring algorithms execute in under 1ms, providing instant mode switching.