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:
Kelly
2025-12-05 16:10:15 -07:00
parent d120a07ed7
commit a0f8d3911c
179 changed files with 140234 additions and 600 deletions

View 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 [];
}
}