Files
cannaiq/findagram/frontend/src/api/client.js
Kelly 53445fe72a fix: Findagram brands page crash and PWA icon errors
- Fix mapBrandForUI to use correct 'brand' field from API response
- Add null check in Brands.jsx filter to prevent crash on undefined names
- Fix BrandPenetrationService sps.brand_name -> sps.brand_name_raw
- Remove missing logo192.png and logo512.png from PWA manifest

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:06:23 -07:00

539 lines
16 KiB
JavaScript

/**
* 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;