// Findadispo API Client // Connects to /api/v1/* endpoints with X-API-Key authentication import { API_CONFIG } from '../lib/utils'; const API_BASE_URL = API_CONFIG.DATA_API_URL; const API_KEY = API_CONFIG.DATA_API_KEY; // Helper function to make authenticated API requests async function apiRequest(endpoint, options = {}) { const url = `${API_BASE_URL}${endpoint}`; const headers = { 'Content-Type': 'application/json', ...(API_KEY && { 'X-API-Key': API_KEY }), ...options.headers, }; const response = await fetch(url, { ...options, headers, }); if (!response.ok) { const error = await response.json().catch(() => ({ message: 'Request failed' })); throw new Error(error.message || `API request failed: ${response.status}`); } return response.json(); } /** * Fetch dispensaries with optional filters * @param {Object} params - Query parameters * @param {string} params.search - Search query (name, city, zip) * @param {string} params.state - State filter * @param {string} params.city - City filter * @param {number} params.limit - Results per page * @param {number} params.offset - Pagination offset * @returns {Promise<{dispensaries: Array, total: number, limit: number, offset: number}>} */ export async function getDispensaries(params = {}) { const queryParams = new URLSearchParams(); if (params.search) queryParams.append('search', params.search); if (params.state) queryParams.append('state', params.state); if (params.city) queryParams.append('city', params.city); if (params.limit) queryParams.append('limit', params.limit); if (params.offset) queryParams.append('offset', params.offset); const queryString = queryParams.toString(); const endpoint = `/api/v1/dispensaries${queryString ? `?${queryString}` : ''}`; return apiRequest(endpoint); } /** * Fetch a single dispensary by slug or ID * @param {string} slugOrId - Dispensary slug or ID * @returns {Promise} */ export async function getDispensaryBySlug(slugOrId) { return apiRequest(`/api/v1/dispensaries/${slugOrId}`); } /** * Fetch dispensary by ID * @param {number} id - Dispensary ID * @returns {Promise} */ export async function getDispensaryById(id) { return apiRequest(`/api/v1/dispensaries/${id}`); } /** * Map API dispensary response to UI format * Converts snake_case API fields to camelCase UI fields * and adds any default values for missing data * @param {Object} apiDispensary - Dispensary from API * @returns {Object} - Dispensary formatted for UI */ export function mapDispensaryForUI(apiDispensary) { // Build full address from components const addressParts = [ apiDispensary.address, apiDispensary.city, apiDispensary.state, apiDispensary.zip ].filter(Boolean); const fullAddress = addressParts.join(', '); // Format hours for display let hoursDisplay = 'Hours not available'; if (apiDispensary.hours) { if (typeof apiDispensary.hours === 'string') { hoursDisplay = apiDispensary.hours; } else if (apiDispensary.hours.formatted) { hoursDisplay = apiDispensary.hours.formatted; } else { // Try to format from day-by-day structure hoursDisplay = formatHoursFromObject(apiDispensary.hours); } } // Check if currently open based on hours const isOpen = checkIfOpen(apiDispensary.hours); // Handle location from nested object or flat fields const lat = apiDispensary.location?.latitude || apiDispensary.latitude; const lng = apiDispensary.location?.longitude || apiDispensary.longitude; return { id: apiDispensary.id, name: apiDispensary.dba_name || apiDispensary.name, slug: apiDispensary.slug || generateSlug(apiDispensary.name), address: fullAddress, city: apiDispensary.city, state: apiDispensary.state, zip: apiDispensary.zip, phone: apiDispensary.phone || null, hours: hoursDisplay, hoursData: apiDispensary.hours || null, // Keep raw hours data for open/close logic rating: apiDispensary.rating || 0, reviews: apiDispensary.review_count || 0, distance: apiDispensary.distance || null, lat: lat, lng: lng, image: apiDispensary.image_url || 'https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=400&h=300&fit=crop', isOpen: isOpen, amenities: apiDispensary.amenities || [], description: apiDispensary.description || 'Cannabis dispensary', website: apiDispensary.website, menuUrl: apiDispensary.menu_url, menuType: apiDispensary.menu_type || apiDispensary.platform, productCount: apiDispensary.product_count || 0, inStockCount: apiDispensary.in_stock_count || 0, lastUpdated: apiDispensary.last_updated, dataAvailable: apiDispensary.data_available ?? true, }; } /** * Generate a URL-friendly slug from dispensary name */ function generateSlug(name) { if (!name) return ''; return name .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } /** * Format hours from day-by-day object to readable string */ function formatHoursFromObject(hours) { if (!hours || typeof hours !== 'object') return 'Hours not available'; // Try to create a simple string like "Mon-Sat 9am-9pm" const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']; const shortDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; let result = []; for (let i = 0; i < days.length; i++) { const dayData = hours[days[i]]; if (dayData && dayData.open && dayData.close) { result.push(`${shortDays[i]}: ${formatTime(dayData.open)}-${formatTime(dayData.close)}`); } } return result.length > 0 ? result.join(', ') : 'Hours not available'; } /** * Format 24hr time to 12hr format */ function formatTime(time) { if (!time) return ''; const [hours, minutes] = time.split(':'); const hour = parseInt(hours, 10); const suffix = hour >= 12 ? 'pm' : 'am'; const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour; return minutes === '00' ? `${displayHour}${suffix}` : `${displayHour}:${minutes}${suffix}`; } /** * Check if dispensary is currently open based on hours data */ function checkIfOpen(hours) { if (!hours || typeof hours !== 'object') return true; // Default to open if no data const now = new Date(); const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; const today = dayNames[now.getDay()]; const todayHours = hours[today]; if (!todayHours || !todayHours.open || !todayHours.close) return true; const currentTime = now.getHours() * 60 + now.getMinutes(); const [openHour, openMin] = todayHours.open.split(':').map(Number); const [closeHour, closeMin] = todayHours.close.split(':').map(Number); const openTime = openHour * 60 + (openMin || 0); const closeTime = closeHour * 60 + (closeMin || 0); return currentTime >= openTime && currentTime <= closeTime; } /** * Search dispensaries by query and filters * This function provides a mockData-compatible interface * @param {string} query - Search query * @param {Object} filters - Filters (openNow, minRating, maxDistance, amenities) * @returns {Promise} - Array of dispensaries */ export async function searchDispensaries(query, filters = {}) { try { const params = { search: query || undefined, limit: 100, }; const response = await getDispensaries(params); let dispensaries = (response.dispensaries || []).map(mapDispensaryForUI); // Apply client-side filters that aren't supported by API if (filters.openNow) { dispensaries = dispensaries.filter(d => d.isOpen); } if (filters.minRating) { dispensaries = dispensaries.filter(d => d.rating >= filters.minRating); } if (filters.maxDistance && filters.maxDistance < 100) { dispensaries = dispensaries.filter(d => !d.distance || d.distance <= filters.maxDistance); } if (filters.amenities && filters.amenities.length > 0) { dispensaries = dispensaries.filter(d => filters.amenities.every(amenity => d.amenities.includes(amenity)) ); } return dispensaries; } catch (error) { console.error('Error searching dispensaries:', error); return []; } } /** * Get list of unique cities for filter dropdown * @returns {Promise>} */ export async function getCities() { try { const response = await getDispensaries({ limit: 500 }); const cities = [...new Set( (response.dispensaries || []) .map(d => d.city) .filter(Boolean) .sort() )]; return cities; } catch (error) { console.error('Error fetching cities:', error); return []; } } /** * Get list of unique states for filter dropdown * @returns {Promise>} */ export async function getStates() { try { const response = await getDispensaries({ limit: 500 }); const states = [...new Set( (response.dispensaries || []) .map(d => d.state) .filter(Boolean) .sort() )]; return states; } catch (error) { console.error('Error fetching states:', error); return []; } } // ============================================================ // PRODUCTS // ============================================================ /** * Fetch products for a specific dispensary * @param {number} dispensaryId - Dispensary ID * @param {Object} params - Query parameters * @returns {Promise<{products: Array, pagination: Object}>} */ export async function getDispensaryProducts(dispensaryId, params = {}) { const queryParams = new URLSearchParams(); if (params.category) queryParams.append('category', params.category); if (params.brand) queryParams.append('brand', params.brand); if (params.search) queryParams.append('search', params.search); if (params.inStockOnly) queryParams.append('in_stock_only', 'true'); if (params.limit) queryParams.append('limit', params.limit); if (params.offset) queryParams.append('offset', params.offset); const queryString = queryParams.toString(); const endpoint = `/api/v1/dispensaries/${dispensaryId}/products${queryString ? `?${queryString}` : ''}`; return apiRequest(endpoint); } /** * Get categories available at a dispensary * @param {number} dispensaryId - Dispensary ID * @returns {Promise} */ export async function getDispensaryCategories(dispensaryId) { return apiRequest(`/api/v1/dispensaries/${dispensaryId}/categories`); } /** * Get brands available at a dispensary * @param {number} dispensaryId - Dispensary ID * @returns {Promise} */ export async function getDispensaryBrands(dispensaryId) { return apiRequest(`/api/v1/dispensaries/${dispensaryId}/brands`); } /** * Map API product to UI format * @param {Object} apiProduct - Product from API * @returns {Object} - Product formatted for UI */ export function mapProductForUI(apiProduct) { const p = apiProduct; // Parse price from string or number const parsePrice = (val) => { if (val === null || val === undefined) return null; const num = typeof val === 'string' ? parseFloat(val) : val; return isNaN(num) ? null : num; }; return { id: p.id, name: p.name || p.name_raw, brand: p.brand || p.brand_name || p.brand_name_raw, category: p.category || p.type || p.category_raw, subcategory: p.subcategory || p.subcategory_raw, strainType: p.strain_type, image: p.image_url || p.primary_image_url, thc: p.thc || p.thc_percent || p.thc_percentage, cbd: p.cbd || p.cbd_percent || p.cbd_percentage, price: parsePrice(p.price_rec) || parsePrice(p.regular_price) || parsePrice(p.price), salePrice: parsePrice(p.price_rec_special) || parsePrice(p.sale_price), inStock: p.in_stock !== undefined ? p.in_stock : p.stock_status === 'in_stock', stockStatus: p.stock_status, onSale: p.on_special || p.special || false, updatedAt: p.updated_at || p.snapshot_at, }; } /** * Get aggregate stats (product count, brand count, dispensary count) * @returns {Promise} */ export async function getStats() { return apiRequest('/api/v1/stats'); }