/** * Findagram API Client * * Connects to the backend /api/v1/* public endpoints. * Uses REACT_APP_API_URL environment variable for the base URL. * * Local development: http://localhost:3010 * Production: https://cannaiq.co (shared API backend) */ const API_BASE_URL = process.env.REACT_APP_API_URL || ''; /** * Make a fetch request to the API */ async function request(endpoint, options = {}) { const url = `${API_BASE_URL}${endpoint}`; const response = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...options.headers, }, }); if (!response.ok) { const error = await response.json().catch(() => ({ error: 'Request failed' })); throw new Error(error.error || `HTTP ${response.status}`); } return response.json(); } /** * Build query string from params object */ function buildQueryString(params) { const filtered = Object.entries(params).filter(([_, v]) => v !== undefined && v !== null && v !== ''); if (filtered.length === 0) return ''; return '?' + new URLSearchParams(filtered).toString(); } // ============================================================ // PRODUCTS // ============================================================ /** * Search/filter products across all dispensaries * * @param {Object} params * @param {string} [params.search] - Search term (name or brand) * @param {string} [params.type] - Category type (flower, concentrates, edibles, etc.) * @param {string} [params.subcategory] - Subcategory * @param {string} [params.brandName] - Brand name filter * @param {string} [params.stockStatus] - Stock status (in_stock, out_of_stock, unknown) * @param {number} [params.storeId] - Filter to specific store * @param {number} [params.limit=50] - Page size * @param {number} [params.offset=0] - Offset for pagination */ export async function getProducts(params = {}) { const queryString = buildQueryString({ search: params.search, type: params.type, subcategory: params.subcategory, brandName: params.brandName, stockStatus: params.stockStatus, storeId: params.storeId, limit: params.limit || 50, offset: params.offset || 0, }); return request(`/api/v1/products${queryString}`); } /** * Get a single product by ID */ export async function getProduct(id) { return request(`/api/v1/products/${id}`); } /** * Get product availability - which dispensaries carry this product * * @param {number|string} productId - Product ID * @param {Object} params * @param {number} params.lat - User latitude * @param {number} params.lng - User longitude * @param {number} [params.maxRadiusMiles=50] - Maximum search radius in miles * @returns {Promise<{productId: number, productName: string, brandName: string, totalCount: number, offers: Array}>} */ export async function getProductAvailability(productId, params = {}) { const { lat, lng, maxRadiusMiles = 50 } = params; if (!lat || !lng) { throw new Error('lat and lng are required'); } const queryString = buildQueryString({ lat, lng, max_radius_miles: maxRadiusMiles, }); return request(`/api/v1/products/${productId}/availability${queryString}`); } /** * Get similar products (same brand + category) * * @param {number|string} productId - Product ID * @returns {Promise<{similarProducts: Array<{productId: number, name: string, brandName: string, imageUrl: string, price: number}>}>} */ export async function getSimilarProducts(productId) { return request(`/api/v1/products/${productId}/similar`); } /** * Get products for a specific store with filters */ export async function getStoreProducts(storeId, params = {}) { const queryString = buildQueryString({ search: params.search, type: params.type, subcategory: params.subcategory, brandName: params.brandName, stockStatus: params.stockStatus, limit: params.limit || 50, offset: params.offset || 0, }); return request(`/api/v1/dispensaries/${storeId}/products${queryString}`); } // ============================================================ // DISPENSARIES (STORES) // ============================================================ /** * Get all dispensaries * * @param {Object} params * @param {string} [params.city] - Filter by city * @param {boolean} [params.hasPlatformId] - Filter by platform ID presence * @param {number} [params.limit=100] - Page size * @param {number} [params.offset=0] - Offset */ export async function getDispensaries(params = {}) { const queryString = buildQueryString({ city: params.city, state: params.state, hasPlatformId: params.hasPlatformId, has_products: params.hasProducts ? 'true' : undefined, limit: params.limit || 100, offset: params.offset || 0, }); return request(`/api/v1/dispensaries${queryString}`); } /** * Get a single dispensary by ID */ export async function getDispensary(id) { return request(`/api/v1/dispensaries/${id}`); } /** * Get dispensary by slug or platform ID */ export async function getDispensaryBySlug(slug) { return request(`/api/v1/dispensaries/slug/${slug}`); } /** * Get dispensary summary (product counts, categories, brands) */ export async function getDispensarySummary(id) { return request(`/api/v1/dispensaries/${id}/summary`); } /** * Get brands available at a specific dispensary */ export async function getDispensaryBrands(id) { return request(`/api/v1/dispensaries/${id}/brands`); } /** * Get categories available at a specific dispensary */ export async function getDispensaryCategories(id) { return request(`/api/v1/dispensaries/${id}/categories`); } // ============================================================ // CATEGORIES // ============================================================ /** * Get all categories with product counts */ export async function getCategories() { return request('/api/v1/categories'); } // ============================================================ // BRANDS // ============================================================ /** * Get all brands with product counts * * @param {Object} params * @param {number} [params.limit=100] - Page size * @param {number} [params.offset=0] - Offset */ export async function getBrands(params = {}) { const queryString = buildQueryString({ limit: params.limit || 100, offset: params.offset || 0, }); return request(`/api/v1/brands${queryString}`); } // ============================================================ // STATS // ============================================================ /** * Get aggregate stats (product count, brand count, dispensary count) */ export async function getStats() { return request('/api/v1/stats'); } // ============================================================ // DEALS / SPECIALS // ============================================================ /** * Get products on special/sale * Uses the on_special filter parameter on the products endpoint * * @param {Object} params * @param {string} [params.type] - Category type filter * @param {string} [params.brandName] - Brand name filter * @param {number} [params.limit=100] - Page size * @param {number} [params.offset=0] - Offset for pagination */ export async function getSpecials(params = {}) { const queryString = buildQueryString({ on_special: 'true', type: params.type, brandName: params.brandName, stockStatus: params.stockStatus || 'in_stock', limit: params.limit || 100, offset: params.offset || 0, }); return request(`/api/v1/products${queryString}`); } /** * Alias for getSpecials for backward compatibility */ export async function getDeals(params = {}) { return getSpecials(params); } // ============================================================ // SEARCH (convenience wrapper) // ============================================================ /** * Search products by term */ export async function searchProducts(searchTerm, params = {}) { return getProducts({ ...params, search: searchTerm, }); } // ============================================================ // FIELD MAPPING HELPERS // ============================================================ /** * Map API product to UI-compatible format * Backend returns snake_case, UI expects camelCase with specific field names * * @param {Object} apiProduct - Product from API * @returns {Object} - Product formatted for UI components */ export function mapProductForUI(apiProduct) { // Handle both direct product and transformed product formats const p = apiProduct; // Helper to parse price (API returns strings like "29.99" or null) const parsePrice = (val) => { if (val === null || val === undefined) return null; const num = typeof val === 'string' ? parseFloat(val) : val; return isNaN(num) ? null : num; }; const regularPrice = parsePrice(p.regular_price); const salePrice = parsePrice(p.sale_price); const medPrice = parsePrice(p.med_price); const medSalePrice = parsePrice(p.med_sale_price); const regularPriceMax = parsePrice(p.regular_price_max); return { id: p.id, name: p.name, brand: p.brand || p.brand_name, category: p.type || p.category || p.category_raw, subcategory: p.subcategory || p.subcategory_raw, strainType: p.strain_type || null, // Images image: p.image_url || p.primary_image_url || null, // Potency thc: p.thc_percentage || p.thc_content || null, cbd: p.cbd_percentage || p.cbd_content || null, // Prices (parsed to numbers) price: regularPrice, priceRange: regularPriceMax && regularPrice ? { min: regularPrice, max: regularPriceMax } : null, onSale: !!(salePrice || medSalePrice), salePrice: salePrice, medPrice: medPrice, medSalePrice: medSalePrice, // Stock inStock: p.in_stock !== undefined ? p.in_stock : p.stock_status === 'in_stock', stockStatus: p.stock_status, // Store info (if available) storeName: p.store_name, storeCity: p.store_city, storeSlug: p.store_slug, dispensaryId: p.dispensary_id, // Options/variants options: p.options, totalQuantity: p.total_quantity, // Timestamps updatedAt: p.updated_at || p.snapshot_at, // For compatibility with ProductCard expectations rating: null, // Not available from API reviewCount: null, // Not available from API dispensaries: [], // Not populated in list view dispensaryCount: p.store_name ? 1 : 0, }; } /** * Map API category to UI-compatible format */ export function mapCategoryForUI(apiCategory) { return { id: apiCategory.type, name: formatCategoryName(apiCategory.type), slug: apiCategory.type?.toLowerCase().replace(/\s+/g, '-'), subcategory: apiCategory.subcategory, productCount: parseInt(apiCategory.product_count || 0, 10), dispensaryCount: parseInt(apiCategory.dispensary_count || 0, 10), brandCount: parseInt(apiCategory.brand_count || 0, 10), }; } /** * Map API brand to UI-compatible format */ export function mapBrandForUI(apiBrand) { // API returns 'brand' field (see /api/v1/brands endpoint) const brandName = apiBrand.brand || apiBrand.brand_name || ''; return { id: brandName, name: brandName, slug: brandName ? brandName.toLowerCase().replace(/\s+/g, '-') : '', logo: apiBrand.brand_logo_url || null, productCount: parseInt(apiBrand.product_count || 0, 10), dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10), productTypes: apiBrand.product_types || [], }; } /** * Map API dispensary to UI-compatible format */ export function mapDispensaryForUI(apiDispensary) { // Handle location object from API (location.latitude, location.longitude) 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, city: apiDispensary.city, state: apiDispensary.state, address: apiDispensary.address1 || apiDispensary.address, zip: apiDispensary.zip, latitude: lat, longitude: lng, website: apiDispensary.website, menuUrl: apiDispensary.menu_url, imageUrl: apiDispensary.image_url, rating: apiDispensary.rating, reviewCount: apiDispensary.review_count, // Product data from API productCount: apiDispensary.product_count || apiDispensary.totalProducts || 0, inStockCount: apiDispensary.in_stock_count || apiDispensary.inStockCount || 0, brandCount: apiDispensary.brandCount, categoryCount: apiDispensary.categoryCount, // Services services: apiDispensary.services || { pickup: false, delivery: false, curbside: false }, // License type licenseType: apiDispensary.license_type || { medical: false, recreational: false }, }; } /** * Format category name for display */ function formatCategoryName(type) { if (!type) return ''; // Convert "FLOWER" to "Flower", "PRE_ROLLS" to "Pre Rolls", etc. return type .toLowerCase() .replace(/_/g, ' ') .replace(/\b\w/g, c => c.toUpperCase()); } // ============================================================ // CLICK TRACKING // ============================================================ /** * Get cached visitor location from sessionStorage */ function getCachedVisitorLocation() { try { const cached = sessionStorage.getItem('findagram_location'); if (cached) { return JSON.parse(cached); } } catch (err) { // Ignore errors } return null; } /** * Track a product click event * Fire-and-forget - doesn't block UI * * @param {Object} params * @param {string} params.productId - Product ID (required) * @param {string} [params.storeId] - Store/dispensary ID * @param {string} [params.brandId] - Brand name/ID * @param {string} [params.dispensaryName] - Dispensary name * @param {string} params.action - Action type: view, open_product, open_store, compare * @param {string} params.source - Source identifier (e.g., 'findagram') * @param {string} [params.pageType] - Page type (e.g., 'home', 'dispensary', 'deals') */ export function trackProductClick(params) { // Get visitor's cached location const visitorLocation = getCachedVisitorLocation(); const payload = { product_id: String(params.productId), store_id: params.storeId ? String(params.storeId) : undefined, brand_id: params.brandId || undefined, dispensary_name: params.dispensaryName || undefined, action: params.action || 'view', source: params.source || 'findagram', page_type: params.pageType || undefined, url_path: window.location.pathname, // Visitor location from IP geolocation visitor_city: visitorLocation?.city || undefined, visitor_state: visitorLocation?.state || undefined, visitor_lat: visitorLocation?.lat || undefined, visitor_lng: visitorLocation?.lng || undefined, }; // Fire and forget - don't await fetch(`${API_BASE_URL}/api/events/product-click`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }).catch(() => { // Silently ignore errors - analytics shouldn't break UX }); } // Default export for convenience const api = { // Products getProducts, getProduct, getProductAvailability, getSimilarProducts, getStoreProducts, searchProducts, // Dispensaries getDispensaries, getDispensary, getDispensaryBySlug, getDispensarySummary, getDispensaryBrands, getDispensaryCategories, // Categories & Brands getCategories, getBrands, // Stats getStats, // Deals getDeals, getSpecials, // Mappers mapProductForUI, mapCategoryForUI, mapBrandForUI, mapDispensaryForUI, // Tracking trackProductClick, }; export default api;