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.Pagination configuration with
numItems and cursorOptional category filter to show only stores in specific category
Copy
const stores = await convex.query(api.customers.explore.getStoresWithPagination, {
paginationOpts: { numItems: 20, cursor: null },
categoryId: "k123456789" // Food & Restaurants
});
Copy
{
"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 coordinate (GPS)
Longitude coordinate (GPS)
Search radius in kilometers (default: 10)
Optional category filter
Copy
// 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
});
Copy
[
{
"_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.Search term to find stores by name
Pagination configuration
Copy
const searchResults = await convex.query(api.customers.explore.searchStores, {
searchTerm: "pizza",
paginationOpts: { numItems: 10, cursor: null }
});
Copy
{
"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:Copy
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:Copy
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
Invalid coordinates
Invalid coordinates
Status Code:
400Copy
{
"error": "Invalid GPS coordinates",
"details": {
"latitude": "Must be a valid latitude (-90 to 90)",
"longitude": "Must be a valid longitude (-180 to 180)"
}
}
No stores found
No stores found
Status Code:
200 (Success with empty results)Copy
{
"page": [],
"isDone": true,
"continueCursor": null
}
Location service unavailable
Location service unavailable
Status Code:
503Copy
{
"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.
