Skip to main content
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 farthest

Mode switching interface

Users switch discovery modes using tabs in the sidebar header:
src/app/page.tsx
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.
The search bar allows users to find specific coffee shops by name:
src/app/page.tsx
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

  1. User types in search box (e.g., “Starbucks”)
  2. Press Enter or click the search button
  3. Query is sent to the API with current location
  4. Results are filtered on the server
  5. Map and sidebar update with matching shops
src/app/page.tsx
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”.
The popular mode uses a multi-criteria sorting function:
src/app/page.tsx
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

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.
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).
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.
.slice(0, 10)
Only the top 10 most popular shops are shown to maintain focus on quality options.
Trending mode uses a weighted scoring system:
src/app/page.tsx
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

FactorFormulaMax PointsPurpose
Open statusshop.isOpen ? 3 : 03.0Heavily favor currently open shops
Ratingrating / 22.5Quality indicator (5-star = 2.5 points)
EngagementtotalRatings / 200VariableNormalize review volume (200+ reviews = 1+ points)
Info completeness(website ? 1 : 0) + (phone ? 1 : 0)2.0Reward shops with contact information
Distance penaltymin(distance / 1000, 3)-3.0Prefer 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
The sidebar displays featured shops above the main list:
src/app/page.tsx
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>
)}

Visible shops computation

The active discovery mode determines which shops appear:
src/app/page.tsx
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:
src/app/page.tsx
{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:
src/app/page.tsx
{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:
src/app/page.tsx
const handleShopSelect = (shop: CoffeeShop) => {
  setSelectedShop(shop)
  panToLocation(shop.location.lat, shop.location.lng, 17)
}
  1. Highlights the shop in the sidebar
  2. Pans the map to the shop’s location
  3. Zooms to level 17 for detailed view
  4. Opens marker popup (automatic)
  5. Maintains selection when switching modes

Performance optimizations

All discovery mode computations use useMemo:
src/app/page.tsx
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.

Build docs developers (and LLMs) love