feat: Add Findagram and FindADispo consumer frontends
- Add findagram.co React frontend with product search, brands, categories - Add findadispo.com React frontend with dispensary locator - Wire findagram to backend /api/az/* endpoints - Update category/brand links to route to /products with filters - Add k8s manifests for both frontends - Add multi-domain user support migrations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
290
findadispo/frontend/src/api/client.js
Normal file
290
findadispo/frontend/src/api/client.js
Normal file
@@ -0,0 +1,290 @@
|
||||
// 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<Object>}
|
||||
*/
|
||||
export async function getDispensaryBySlug(slugOrId) {
|
||||
return apiRequest(`/api/v1/dispensaries/${slugOrId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch dispensary by ID
|
||||
* @param {number} id - Dispensary ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
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>} - 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<Array<string>>}
|
||||
*/
|
||||
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<Array<string>>}
|
||||
*/
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user