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.PaginationOptions
required
Pagination configuration with
numItems and cursorId<'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
});
const stores = await convex.query("customers.explore.getStoresWithPagination", {
paginationOpts: { numItems: 15, cursor: null }
});
import requests
response = requests.post(
f"{CONVEX_URL}/api/query",
json={
"path": "customers.explore.getStoresWithPagination",
"args": {
"paginationOpts": {"numItems": 20, "cursor": None},
"categoryId": "k123456789"
}
},
headers={"Authorization": f"Bearer {token}"}
)
stores = response.json()
{
"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.number
required
User’s latitude coordinate (GPS)
number
required
User’s longitude coordinate (GPS)
number
Search radius in kilometers (default: platform config default)
Id<'categories'>
Optional category filter
number
Maximum number of stores to return (default: 20)
// Find nearby food stores within 5km
const nearbyStores = await convex.query(api.customers.explore.getNearbyStores, {
userLatitude: 25.2048, // Dubai Marina
userLongitude: 55.2708,
radiusKm: 5,
categoryId: "k123456789", // Food category
limit: 10
});
// Find all nearby stores within default radius
const nearbyStores = await convex.query("customers.explore.getNearbyStores", {
userLatitude: 25.2048,
userLongitude: 55.2708
});
response = requests.post(
f"{CONVEX_URL}/api/query",
json={
"path": "customers.explore.getNearbyStores",
"args": {
"userLatitude": 25.2048,
"userLongitude": 55.2708,
"radiusKm": 3,
"categoryId": "k123456789"
}
},
headers={"Authorization": f"Bearer {token}"}
)
nearby_stores = response.json()
[
{
"_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
}
]
Get Store by ID
Get a specific store by ID for customer viewing. Returns store details with follow status if a customer ID is provided.Id<'stores'>
required
Store ID to retrieve
Id<'customers'>
Optional customer ID to check follow status
const store = await convex.query(api.customers.explore.getStoreById, {
storeId: "j123456789",
customerId: "c123456789" // optional
});
const store = await convex.query("customers.explore.getStoreById", {
storeId: "j123456789"
});
{
"_id": "j123456789",
"name": "Mario's Pizza Palace",
"primaryCategory": "k123456789",
"logoUrl": "https://storage.convex.dev/mario-logo.jpg",
"coverImageUrl": "https://storage.convex.dev/mario-cover.jpg",
"status": "approved",
"isActive": true,
"isFollowingStore": true,
"ownerName": "Mario Rossi",
"phoneNumber": "+971501234567",
"settings": {
"deliveryEnabled": true,
"pickupEnabled": true
},
"address": {
"fullAddress": "Dubai Marina, Dubai",
"latitude": "25.2055",
"longitude": "55.2710"
},
"followersCount": 120,
"_creationTime": 1640995200000
}
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'>('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 ? {
userLatitude: parseFloat(currentLocation.lat),
userLongitude: parseFloat(currentLocation.lng),
radiusKm: 10,
categoryId: selectedCategory || undefined
} : "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;
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">
{/* 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 === 'nearby'
? 'No stores found in your area'
: 'No stores available in this category'
}
</p>
</div>
)}
</div>
);
}
export default StoreExplorer;
import { useState, useEffect, useCallback } from 'react';
import { useQuery } from 'convex/react';
import { api } from './convex/_generated/api';
interface UseInfiniteStoresProps {
categoryId?: string;
pageSize?: number;
}
function useInfiniteStores({ categoryId, pageSize = 20 }: UseInfiniteStoresProps = {}) {
const [allStores, setAllStores] = useState<any[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const stores = useQuery(
api.customers.explore.getStoresWithPagination,
!loading ? {
paginationOpts: { numItems: pageSize, cursor },
categoryId
} : "skip"
);
useEffect(() => {
if (stores) {
setLoading(false);
if (cursor === null) {
// First load
setAllStores(stores.page);
} else {
// Append new stores
setAllStores(prev => [...prev, ...stores.page]);
}
setHasMore(!stores.isDone);
setCursor(stores.continueCursor);
}
}, [stores]);
const loadMore = useCallback(() => {
if (hasMore && !loading && stores?.continueCursor) {
setLoading(true);
setCursor(stores.continueCursor);
}
}, [hasMore, loading, stores?.continueCursor]);
const reset = useCallback(() => {
setAllStores([]);
setCursor(null);
setHasMore(true);
setLoading(false);
}, []);
return {
stores: allStores,
hasMore,
loading,
loadMore,
reset
};
}
// Usage with intersection observer for infinite scroll
function InfiniteStoreList() {
const { stores, hasMore, loading, loadMore } = useInfiniteStores({
pageSize: 12
});
const observerRef = useCallback((node: HTMLDivElement) => {
if (loading) return;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMore();
}
});
if (node) observer.observe(node);
return () => observer.disconnect();
}, [loading, hasMore, loadMore]);
return (
<div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{stores.map((store, index) => (
<StoreCard
key={store._id}
store={store}
ref={index === stores.length - 1 ? observerRef : undefined}
/>
))}
</div>
{loading && (
<div className="text-center py-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
</div>
)}
</div>
);
}
export { StoreExplorer, useInfiniteStores, InfiniteStoreList };
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
Invalid coordinates
Invalid coordinates
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)"
}
}
No stores found
No stores found
Status Code:
200 (Success with empty results){
"page": [],
"isDone": true,
"continueCursor": null
}
Location service unavailable
Location service unavailable
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.
