Skip to main content

Overview

The Store Exploration API provides comprehensive store discovery functionality including paginated browsing, location-based search, category filtering, and text search. This system powers the main store discovery experience for customers. Location: convex/customers/explore.ts

Get Stores with Pagination

Retrieve stores with pagination and optional category filtering, perfect for infinite scroll implementations.
paginationOpts
PaginationOptions
required
Pagination configuration with numItems and cursor
categoryId
Id<'categories'>
Optional category filter to show only stores in specific category
const stores = await convex.query(api.customers.explore.getStoresWithPagination, {
  paginationOpts: { numItems: 20, cursor: null },
  categoryId: "k123456789" // Food & Restaurants
});
{
  "page": [
    {
      "_id": "j123456789",
      "name": "Mario's Pizza Palace",
      "primaryCategory": "k123456789",
      "categoryName": "Food & Restaurants",
      "logoUrl": "https://storage.convex.dev/mario-logo.jpg",
      "coverImageUrl": "https://storage.convex.dev/mario-cover.jpg",
      "status": "approved",
      "settings": {
        "deliveryEnabled": true,
        "pickupEnabled": true,
        "storeLive": true
      },
      "stats": {
        "averageRating": 4.8,
        "totalReviews": 125,
        "totalOrders": 890,
        "responseTime": "15-30 min"
      },
      "location": {
        "city": "Dubai",
        "area": "Marina"
      },
      "_creationTime": 1640995200000
    },
    {
      "_id": "j987654321",
      "name": "Luigi's Kitchen",
      "primaryCategory": "k123456789",
      "categoryName": "Food & Restaurants",
      "logoUrl": "https://storage.convex.dev/luigi-logo.jpg",
      "status": "approved",
      "settings": {
        "deliveryEnabled": true,
        "pickupEnabled": false,
        "storeLive": true
      },
      "stats": {
        "averageRating": 4.6,
        "totalReviews": 89,
        "totalOrders": 456,
        "responseTime": "20-35 min"
      },
      "location": {
        "city": "Dubai",
        "area": "Downtown"
      },
      "_creationTime": 1640994000000
    }
  ],
  "isDone": false,
  "continueCursor": "eyJfaWQiOiJqOTg3NjU0MzIxIn0",
  "pageStatus": "success",
  "splitCursor": null
}

Get Nearby Stores

Find stores within a specified radius of given coordinates with distance calculation.
latitude
string
required
Latitude coordinate (GPS)
longitude
string
required
Longitude coordinate (GPS)
radiusKm
number
Search radius in kilometers (default: 10)
categoryId
Id<'categories'>
Optional category filter
// Find nearby food stores within 5km
const nearbyStores = await convex.query(api.customers.explore.getNearbyStores, {
  latitude: "25.2048", // Dubai Marina
  longitude: "55.2708",
  radiusKm: 5,
  categoryId: "k123456789" // Food category
});
[
  {
    "_id": "j123456789",
    "name": "Mario's Pizza Palace",
    "logoUrl": "https://storage.convex.dev/mario-logo.jpg",
    "categoryName": "Food & Restaurants",
    "stats": {
      "averageRating": 4.8,
      "totalReviews": 125
    },
    "settings": {
      "deliveryEnabled": true,
      "storeLive": true
    },
    "location": {
      "city": "Dubai",
      "area": "Marina",
      "coordinates": {
        "latitude": "25.2055",
        "longitude": "55.2710"
      }
    },
    "distance": 0.8
  },
  {
    "_id": "j987654321",
    "name": "Tony's Trattoria",
    "logoUrl": "https://storage.convex.dev/tony-logo.jpg",
    "categoryName": "Food & Restaurants",
    "stats": {
      "averageRating": 4.5,
      "totalReviews": 78
    },
    "settings": {
      "deliveryEnabled": true,
      "storeLive": true
    },
    "location": {
      "city": "Dubai",
      "area": "JBR",
      "coordinates": {
        "latitude": "25.2020",
        "longitude": "55.2690"
      }
    },
    "distance": 2.3
  }
]

Search Stores

Search stores by name with pagination support.
searchTerm
string
required
Search term to find stores by name
paginationOpts
PaginationOptions
required
Pagination configuration
const searchResults = await convex.query(api.customers.explore.searchStores, {
  searchTerm: "pizza",
  paginationOpts: { numItems: 10, cursor: null }
});
{
  "page": [
    {
      "_id": "j123456789",
      "name": "Mario's Pizza Palace",
      "logoUrl": "https://storage.convex.dev/mario-logo.jpg",
      "categoryName": "Food & Restaurants",
      "stats": {
        "averageRating": 4.8,
        "totalReviews": 125
      },
      "location": {
        "city": "Dubai",
        "area": "Marina"
      },
      "matchScore": 0.95
    }
  ],
  "isDone": true,
  "continueCursor": null
}

Store Discovery Components

Complete store exploration interface:
import { useState, useEffect } from 'react';
import { useQuery } from 'convex/react';
import { api } from './convex/_generated/api';

interface StoreExplorerProps {
  onStoreSelect: (storeId: string) => void;
}

function StoreExplorer({ onStoreSelect }: StoreExplorerProps) {
  const [searchTerm, setSearchTerm] = useState('');
  const [selectedCategory, setSelectedCategory] = useState<string>('');
  const [currentLocation, setCurrentLocation] = useState<{lat: string, lng: string} | null>(null);
  const [viewMode, setViewMode] = useState<'all' | 'nearby' | 'search'>('all');
  const [cursor, setCursor] = useState<string | null>(null);

  // API queries
  const categories = useQuery(api.shared.categories.getParents);
  
  const stores = useQuery(
    api.customers.explore.getStoresWithPagination,
    viewMode === 'all' ? {
      paginationOpts: { numItems: 20, cursor },
      categoryId: selectedCategory || undefined
    } : "skip"
  );

  const nearbyStores = useQuery(
    api.customers.explore.getNearbyStores,
    viewMode === 'nearby' && currentLocation ? {
      latitude: currentLocation.lat,
      longitude: currentLocation.lng,
      radiusKm: 10,
      categoryId: selectedCategory || undefined
    } : "skip"
  );

  const searchResults = useQuery(
    api.customers.explore.searchStores,
    viewMode === 'search' && searchTerm.length > 2 ? {
      searchTerm,
      paginationOpts: { numItems: 20, cursor }
    } : "skip"
  );

  // Get user location
  useEffect(() => {
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        (position) => {
          setCurrentLocation({
            lat: position.coords.latitude.toString(),
            lng: position.coords.longitude.toString()
          });
        },
        (error) => {
          console.warn('Location access denied:', error);
        }
      );
    }
  }, []);

  const getCurrentStores = () => {
    switch (viewMode) {
      case 'nearby':
        return nearbyStores ? { page: nearbyStores, isDone: true, continueCursor: null } : null;
      case 'search':
        return searchResults;
      default:
        return stores;
    }
  };

  const currentStores = getCurrentStores();

  const handleLoadMore = () => {
    if (currentStores && !currentStores.isDone && currentStores.continueCursor) {
      setCursor(currentStores.continueCursor);
    }
  };

  return (
    <div className="max-w-6xl mx-auto space-y-6">
      {/* Header */}
      <div className="text-center">
        <h1 className="text-3xl font-bold text-gray-900">Explore Stores</h1>
        <p className="text-gray-600 mt-2">Discover amazing local businesses in your area</p>
      </div>

      {/* Search and Filters */}
      <div className="bg-white p-6 rounded-lg shadow">
        <div className="flex flex-col md:flex-row gap-4">
          {/* Search Input */}
          <div className="flex-1">
            <input
              type="text"
              placeholder="Search stores..."
              value={searchTerm}
              onChange={(e) => {
                setSearchTerm(e.target.value);
                setViewMode(e.target.value.length > 2 ? 'search' : 'all');
                setCursor(null);
              }}
              className="w-full p-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
            />
          </div>

          {/* Category Filter */}
          <div className="md:w-64">
            <select
              value={selectedCategory}
              onChange={(e) => {
                setSelectedCategory(e.target.value);
                setCursor(null);
              }}
              className="w-full p-3 border border-gray-300 rounded-lg"
            >
              <option value="">All Categories</option>
              {categories?.map((category) => (
                <option key={category._id} value={category._id}>
                  {category.name}
                </option>
              ))}
            </select>
          </div>

          {/* View Mode Buttons */}
          <div className="flex space-x-2">
            <button
              onClick={() => {
                setViewMode('all');
                setCursor(null);
              }}
              className={`px-4 py-2 rounded ${
                viewMode === 'all' 
                  ? 'bg-blue-600 text-white' 
                  : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
              }`}
            >
              All Stores
            </button>
            
            {currentLocation && (
              <button
                onClick={() => {
                  setViewMode('nearby');
                  setCursor(null);
                }}
                className={`px-4 py-2 rounded ${
                  viewMode === 'nearby' 
                    ? 'bg-blue-600 text-white' 
                    : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
                }`}
              >
                📍 Nearby
              </button>
            )}
          </div>
        </div>
      </div>

      {/* Store Grid */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {currentStores?.page.map((store) => (
          <div
            key={store._id}
            onClick={() => onStoreSelect(store._id)}
            className="bg-white rounded-lg shadow hover:shadow-lg transition-shadow cursor-pointer overflow-hidden"
          >
            {/* Store Cover Image */}
            {store.coverImageUrl && (
              <div className="h-32 bg-gray-200">
                <img
                  src={store.coverImageUrl}
                  alt={store.name}
                  className="w-full h-full object-cover"
                />
              </div>
            )}

            <div className="p-4">
              {/* Store Header */}
              <div className="flex items-center space-x-3 mb-3">
                {store.logoUrl && (
                  <img
                    src={store.logoUrl}
                    alt={store.name}
                    className="w-12 h-12 rounded-lg object-cover"
                  />
                )}
                <div className="flex-1 min-w-0">
                  <h3 className="font-semibold text-gray-900 truncate">{store.name}</h3>
                  <p className="text-sm text-gray-600">{store.categoryName}</p>
                </div>
                <div className="flex items-center space-x-1">
                  <span className="text-yellow-400"></span>
                  <span className="text-sm font-medium">{store.stats.averageRating.toFixed(1)}</span>
                </div>
              </div>

              {/* Store Info */}
              <div className="space-y-2">
                <div className="flex items-center justify-between text-sm">
                  <span className="text-gray-600">Reviews:</span>
                  <span className="font-medium">{store.stats.totalReviews}</span>
                </div>
                
                <div className="flex items-center justify-between text-sm">
                  <span className="text-gray-600">Response Time:</span>
                  <span className="font-medium">{store.stats.responseTime}</span>
                </div>

                {store.distance !== undefined && (
                  <div className="flex items-center justify-between text-sm">
                    <span className="text-gray-600">Distance:</span>
                    <span className="font-medium">{store.distance.toFixed(1)} km</span>
                  </div>
                )}

                <div className="flex items-center justify-between text-sm">
                  <span className="text-gray-600">Location:</span>
                  <span className="font-medium">{store.location.area}, {store.location.city}</span>
                </div>
              </div>

              {/* Store Status */}
              <div className="flex items-center space-x-2 mt-3">
                <span className={`px-2 py-1 rounded-full text-xs font-medium ${
                  store.settings.storeLive 
                    ? 'bg-green-100 text-green-800' 
                    : 'bg-gray-100 text-gray-800'
                }`}>
                  {store.settings.storeLive ? '🟢 Open' : '🔴 Closed'}
                </span>
                
                {store.settings.deliveryEnabled && (
                  <span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
                    🚚 Delivery
                  </span>
                )}
                
                {store.settings.pickupEnabled && (
                  <span className="px-2 py-1 bg-purple-100 text-purple-800 rounded-full text-xs font-medium">
                    📦 Pickup
                  </span>
                )}
              </div>
            </div>
          </div>
        ))}
      </div>

      {/* Load More Button */}
      {currentStores && !currentStores.isDone && (
        <div className="text-center">
          <button
            onClick={handleLoadMore}
            className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
          >
            Load More Stores
          </button>
        </div>
      )}

      {/* Empty State */}
      {currentStores && currentStores.page.length === 0 && (
        <div className="text-center py-12">
          <div className="w-24 h-24 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
            <svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
            </svg>
          </div>
          <h3 className="text-lg font-medium text-gray-900 mb-2">No stores found</h3>
          <p className="text-gray-600">
            {viewMode === 'search' 
              ? `No stores found for "${searchTerm}"`
              : viewMode === 'nearby'
              ? 'No stores found in your area'
              : 'No stores available in this category'
            }
          </p>
        </div>
      )}
    </div>
  );
}

export default StoreExplorer;

Location-based Features

GPS Discovery

Find stores near user’s current location with distance calculation and radius filtering

Area-based Search

Browse stores by specific UAE cities and areas for targeted discovery

Delivery Radius

Show only stores that deliver to user’s location based on delivery zones

Real-time Distance

Calculate and display real-time distances from user’s location to stores

Store Filtering Options

Advanced filtering capabilities:
interface StoreFilters {
  category: string;           // Filter by business category
  location: {
    city: string;            // Filter by city
    area: string;            // Filter by area
    radius: number;          // Distance radius in km
    coordinates: {
      lat: string;
      lng: string;
    };
  };
  rating: {
    minimum: number;         // Minimum star rating
  };
  features: {
    delivery: boolean;       // Has delivery service
    pickup: boolean;         // Has pickup service
    isOpen: boolean;         // Currently accepting orders
  };
  sorting: 'distance' | 'rating' | 'name' | 'newest' | 'popular';
}

Error Handling

Status Code: 400
{
  "error": "Invalid GPS coordinates",
  "details": {
    "latitude": "Must be a valid latitude (-90 to 90)",
    "longitude": "Must be a valid longitude (-180 to 180)"
  }
}
Status Code: 200 (Success with empty results)
{
  "page": [],
  "isDone": true,
  "continueCursor": null
}
Status Code: 503
{
  "error": "Location service temporarily unavailable",
  "fallback": "Use category browsing instead"
}
Store exploration results are real-time and automatically update when stores change their status, add new products, or update their information.
Use location-based search to provide personalized store recommendations and improve the customer discovery experience.