- 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>
539 lines
16 KiB
JavaScript
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;
|