feat: Stealth worker system with mandatory proxy rotation
## Worker System - Role-agnostic workers that can handle any task type - Pod-based architecture with StatefulSet (5-15 pods, 5 workers each) - Custom pod names (Aethelgard, Xylos, Kryll, etc.) - Worker registry with friendly names and resource monitoring - Hub-and-spoke visualization on JobQueue page ## Stealth & Anti-Detection (REQUIRED) - Proxies are MANDATORY - workers fail to start without active proxies - CrawlRotator initializes on worker startup - Loads proxies from `proxies` table - Auto-rotates proxy + fingerprint on 403 errors - 12 browser fingerprints (Chrome, Firefox, Safari, Edge) - Locale/timezone matching for geographic consistency ## Task System - Renamed product_resync → product_refresh - Task chaining: store_discovery → entry_point → product_discovery - Priority-based claiming with FOR UPDATE SKIP LOCKED - Heartbeat and stale task recovery ## UI Updates - JobQueue: Pod visualization, resource monitoring on hover - WorkersDashboard: Simplified worker list - Removed unused filters from task list ## Other - IP2Location service for visitor analytics - Findagram consumer features scaffolding - Documentation updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import Header from './components/findagram/Header';
|
||||
import Footer from './components/findagram/Footer';
|
||||
import AuthModal from './components/findagram/AuthModal';
|
||||
|
||||
// Pages
|
||||
import Home from './pages/findagram/Home';
|
||||
@@ -12,6 +14,7 @@ import Brands from './pages/findagram/Brands';
|
||||
import BrandDetail from './pages/findagram/BrandDetail';
|
||||
import Categories from './pages/findagram/Categories';
|
||||
import CategoryDetail from './pages/findagram/CategoryDetail';
|
||||
import DispensaryDetail from './pages/findagram/DispensaryDetail';
|
||||
import About from './pages/findagram/About';
|
||||
import Contact from './pages/findagram/Contact';
|
||||
import Login from './pages/findagram/Login';
|
||||
@@ -23,32 +26,11 @@ import SavedSearches from './pages/findagram/SavedSearches';
|
||||
import Profile from './pages/findagram/Profile';
|
||||
|
||||
function App() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
|
||||
// Mock login function
|
||||
const handleLogin = (email, password) => {
|
||||
// In a real app, this would make an API call
|
||||
setUser({
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
email: email,
|
||||
avatar: null,
|
||||
});
|
||||
setIsLoggedIn(true);
|
||||
return true;
|
||||
};
|
||||
|
||||
// Mock logout function
|
||||
const handleLogout = () => {
|
||||
setUser(null);
|
||||
setIsLoggedIn(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Header isLoggedIn={isLoggedIn} user={user} onLogout={handleLogout} />
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Header />
|
||||
|
||||
<main className="flex-grow">
|
||||
<Routes>
|
||||
@@ -61,12 +43,13 @@ function App() {
|
||||
<Route path="/brands/:slug" element={<BrandDetail />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/categories/:slug" element={<CategoryDetail />} />
|
||||
<Route path="/dispensaries/:slug" element={<DispensaryDetail />} />
|
||||
<Route path="/about" element={<About />} />
|
||||
<Route path="/contact" element={<Contact />} />
|
||||
|
||||
{/* Auth Routes */}
|
||||
<Route path="/login" element={<Login onLogin={handleLogin} />} />
|
||||
<Route path="/signup" element={<Signup onLogin={handleLogin} />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
|
||||
{/* Dashboard Routes */}
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
@@ -77,9 +60,11 @@ function App() {
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</Router>
|
||||
<Footer />
|
||||
<AuthModal />
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function getStoreProducts(storeId, params = {}) {
|
||||
offset: params.offset || 0,
|
||||
});
|
||||
|
||||
return request(`/api/v1/stores/${storeId}/products${queryString}`);
|
||||
return request(`/api/v1/dispensaries/${storeId}/products${queryString}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -149,47 +149,49 @@ export async function getStoreProducts(storeId, params = {}) {
|
||||
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/stores${queryString}`);
|
||||
return request(`/api/v1/dispensaries${queryString}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single dispensary by ID
|
||||
*/
|
||||
export async function getDispensary(id) {
|
||||
return request(`/api/v1/stores/${id}`);
|
||||
return request(`/api/v1/dispensaries/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dispensary by slug or platform ID
|
||||
*/
|
||||
export async function getDispensaryBySlug(slug) {
|
||||
return request(`/api/v1/stores/slug/${slug}`);
|
||||
return request(`/api/v1/dispensaries/slug/${slug}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dispensary summary (product counts, categories, brands)
|
||||
*/
|
||||
export async function getDispensarySummary(id) {
|
||||
return request(`/api/v1/stores/${id}/summary`);
|
||||
return request(`/api/v1/dispensaries/${id}/summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brands available at a specific dispensary
|
||||
*/
|
||||
export async function getDispensaryBrands(id) {
|
||||
return request(`/api/v1/stores/${id}/brands`);
|
||||
return request(`/api/v1/dispensaries/${id}/brands`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories available at a specific dispensary
|
||||
*/
|
||||
export async function getDispensaryCategories(id) {
|
||||
return request(`/api/v1/stores/${id}/categories`);
|
||||
return request(`/api/v1/dispensaries/${id}/categories`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -224,29 +226,48 @@ export async function getBrands(params = {}) {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DEALS / SPECIALS
|
||||
// Note: The /api/az routes don't have a dedicated specials endpoint yet.
|
||||
// For now, we can filter products with sale prices or use dispensary-specific specials.
|
||||
// STATS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get products on sale (products where sale_price exists)
|
||||
* This is a client-side filter until a dedicated endpoint is added.
|
||||
* Get aggregate stats (product count, brand count, dispensary count)
|
||||
*/
|
||||
export async function getDeals(params = {}) {
|
||||
// For now, get products and we'll need to filter client-side
|
||||
// or we could use the /api/dispensaries/:slug/specials endpoint if we have a dispensary context
|
||||
const result = await getProducts({
|
||||
...params,
|
||||
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,
|
||||
});
|
||||
|
||||
// Filter to only products with a sale price
|
||||
// Note: This is a temporary solution - ideally the backend would support this filter
|
||||
return {
|
||||
...result,
|
||||
products: result.products.filter(p => p.sale_price || p.med_sale_price),
|
||||
};
|
||||
return request(`/api/v1/products${queryString}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias for getSpecials for backward compatibility
|
||||
*/
|
||||
export async function getDeals(params = {}) {
|
||||
return getSpecials(params);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -278,27 +299,40 @@ 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,
|
||||
subcategory: p.subcategory,
|
||||
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 (API returns dollars as numbers or null)
|
||||
price: p.regular_price || null,
|
||||
priceRange: p.regular_price_max && p.regular_price
|
||||
? { min: p.regular_price, max: p.regular_price_max }
|
||||
// Prices (parsed to numbers)
|
||||
price: regularPrice,
|
||||
priceRange: regularPriceMax && regularPrice
|
||||
? { min: regularPrice, max: regularPriceMax }
|
||||
: null,
|
||||
onSale: !!(p.sale_price || p.med_sale_price),
|
||||
salePrice: p.sale_price || null,
|
||||
medPrice: p.med_price || null,
|
||||
medSalePrice: p.med_sale_price || 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,
|
||||
@@ -354,23 +388,41 @@ export function mapBrandForUI(apiBrand) {
|
||||
* 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.address,
|
||||
address: apiDispensary.address1 || apiDispensary.address,
|
||||
zip: apiDispensary.zip,
|
||||
latitude: apiDispensary.latitude,
|
||||
longitude: apiDispensary.longitude,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
website: apiDispensary.website,
|
||||
menuUrl: apiDispensary.menu_url,
|
||||
// Summary data (if fetched with summary)
|
||||
productCount: apiDispensary.totalProducts,
|
||||
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,
|
||||
inStockCount: apiDispensary.inStockCount,
|
||||
// Services
|
||||
services: apiDispensary.services || {
|
||||
pickup: false,
|
||||
delivery: false,
|
||||
curbside: false
|
||||
},
|
||||
// License type
|
||||
licenseType: apiDispensary.license_type || {
|
||||
medical: false,
|
||||
recreational: false
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -386,6 +438,68 @@ function formatCategoryName(type) {
|
||||
.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
|
||||
@@ -405,13 +519,18 @@ const api = {
|
||||
// Categories & Brands
|
||||
getCategories,
|
||||
getBrands,
|
||||
// Stats
|
||||
getStats,
|
||||
// Deals
|
||||
getDeals,
|
||||
getSpecials,
|
||||
// Mappers
|
||||
mapProductForUI,
|
||||
mapCategoryForUI,
|
||||
mapBrandForUI,
|
||||
mapDispensaryForUI,
|
||||
// Tracking
|
||||
trackProductClick,
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
302
findagram/frontend/src/api/consumer.js
Normal file
302
findagram/frontend/src/api/consumer.js
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Consumer API Client for Findagram
|
||||
*
|
||||
* Handles authenticated requests for:
|
||||
* - Favorites
|
||||
* - Price Alerts
|
||||
* - Saved Searches
|
||||
*
|
||||
* All methods require auth token (use with AuthContext's authFetch)
|
||||
*/
|
||||
|
||||
// ============================================================
|
||||
// FAVORITES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all user's favorites
|
||||
* @param {Function} authFetch - Authenticated fetch from AuthContext
|
||||
*/
|
||||
export async function getFavorites(authFetch) {
|
||||
return authFetch('/api/consumer/favorites');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add product to favorites
|
||||
* @param {Function} authFetch
|
||||
* @param {number} productId
|
||||
* @param {number} [dispensaryId] - Optional dispensary context
|
||||
*/
|
||||
export async function addFavorite(authFetch, productId, dispensaryId = null) {
|
||||
return authFetch('/api/consumer/favorites', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ productId, dispensaryId }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove favorite by favorite ID
|
||||
* @param {Function} authFetch
|
||||
* @param {number} favoriteId
|
||||
*/
|
||||
export async function removeFavorite(authFetch, favoriteId) {
|
||||
return authFetch(`/api/consumer/favorites/${favoriteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove favorite by product ID
|
||||
* @param {Function} authFetch
|
||||
* @param {number} productId
|
||||
*/
|
||||
export async function removeFavoriteByProduct(authFetch, productId) {
|
||||
return authFetch(`/api/consumer/favorites/product/${productId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if product is favorited
|
||||
* @param {Function} authFetch
|
||||
* @param {number} productId
|
||||
* @returns {Promise<{isFavorited: boolean}>}
|
||||
*/
|
||||
export async function checkFavorite(authFetch, productId) {
|
||||
return authFetch(`/api/consumer/favorites/check/product/${productId}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ALERTS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all user's alerts
|
||||
* @param {Function} authFetch
|
||||
*/
|
||||
export async function getAlerts(authFetch) {
|
||||
return authFetch('/api/consumer/alerts');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert statistics
|
||||
* @param {Function} authFetch
|
||||
*/
|
||||
export async function getAlertStats(authFetch) {
|
||||
return authFetch('/api/consumer/alerts/stats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a price drop alert
|
||||
* @param {Function} authFetch
|
||||
* @param {Object} params
|
||||
* @param {number} params.productId - Product to track
|
||||
* @param {number} params.targetPrice - Price to alert at
|
||||
* @param {number} [params.dispensaryId] - Optional dispensary context
|
||||
*/
|
||||
export async function createPriceAlert(authFetch, { productId, targetPrice, dispensaryId }) {
|
||||
return authFetch('/api/consumer/alerts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
alertType: 'price_drop',
|
||||
productId,
|
||||
targetPrice,
|
||||
dispensaryId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a back-in-stock alert
|
||||
* @param {Function} authFetch
|
||||
* @param {Object} params
|
||||
* @param {number} params.productId - Product to track
|
||||
* @param {number} [params.dispensaryId] - Optional dispensary context
|
||||
*/
|
||||
export async function createStockAlert(authFetch, { productId, dispensaryId }) {
|
||||
return authFetch('/api/consumer/alerts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
alertType: 'back_in_stock',
|
||||
productId,
|
||||
dispensaryId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a brand/category alert
|
||||
* @param {Function} authFetch
|
||||
* @param {Object} params
|
||||
* @param {string} [params.brand] - Brand to track
|
||||
* @param {string} [params.category] - Category to track
|
||||
*/
|
||||
export async function createBrandCategoryAlert(authFetch, { brand, category }) {
|
||||
return authFetch('/api/consumer/alerts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
alertType: 'product_on_special',
|
||||
brand,
|
||||
category,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an alert
|
||||
* @param {Function} authFetch
|
||||
* @param {number} alertId
|
||||
* @param {Object} updates
|
||||
* @param {boolean} [updates.isActive]
|
||||
* @param {number} [updates.targetPrice]
|
||||
*/
|
||||
export async function updateAlert(authFetch, alertId, updates) {
|
||||
return authFetch(`/api/consumer/alerts/${alertId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle alert active status
|
||||
* @param {Function} authFetch
|
||||
* @param {number} alertId
|
||||
*/
|
||||
export async function toggleAlert(authFetch, alertId) {
|
||||
return authFetch(`/api/consumer/alerts/${alertId}/toggle`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an alert
|
||||
* @param {Function} authFetch
|
||||
* @param {number} alertId
|
||||
*/
|
||||
export async function deleteAlert(authFetch, alertId) {
|
||||
return authFetch(`/api/consumer/alerts/${alertId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SAVED SEARCHES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all user's saved searches
|
||||
* @param {Function} authFetch
|
||||
*/
|
||||
export async function getSavedSearches(authFetch) {
|
||||
return authFetch('/api/consumer/saved-searches');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a saved search
|
||||
* @param {Function} authFetch
|
||||
* @param {Object} params
|
||||
* @param {string} params.name - Display name
|
||||
* @param {string} [params.query] - Search query
|
||||
* @param {string} [params.category] - Category filter
|
||||
* @param {string} [params.brand] - Brand filter
|
||||
* @param {string} [params.strainType] - Strain type filter
|
||||
* @param {number} [params.minPrice] - Min price filter
|
||||
* @param {number} [params.maxPrice] - Max price filter
|
||||
* @param {number} [params.minThc] - Min THC filter
|
||||
* @param {number} [params.maxThc] - Max THC filter
|
||||
* @param {boolean} [params.notifyOnNew] - Notify on new products
|
||||
* @param {boolean} [params.notifyOnPriceDrop] - Notify on price drops
|
||||
*/
|
||||
export async function createSavedSearch(authFetch, params) {
|
||||
return authFetch('/api/consumer/saved-searches', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a saved search
|
||||
* @param {Function} authFetch
|
||||
* @param {number} searchId
|
||||
* @param {Object} updates
|
||||
*/
|
||||
export async function updateSavedSearch(authFetch, searchId, updates) {
|
||||
return authFetch(`/api/consumer/saved-searches/${searchId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved search
|
||||
* @param {Function} authFetch
|
||||
* @param {number} searchId
|
||||
*/
|
||||
export async function deleteSavedSearch(authFetch, searchId) {
|
||||
return authFetch(`/api/consumer/saved-searches/${searchId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a saved search (get search params)
|
||||
* @param {Function} authFetch
|
||||
* @param {number} searchId
|
||||
* @returns {Promise<{searchParams: Object, searchUrl: string}>}
|
||||
*/
|
||||
export async function runSavedSearch(authFetch, searchId) {
|
||||
return authFetch(`/api/consumer/saved-searches/${searchId}/run`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HELPER: Generate search name from filters
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Generate a display name for a search based on filters
|
||||
* @param {Object} filters
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateSearchName(filters) {
|
||||
const parts = [];
|
||||
|
||||
if (filters.query || filters.search) parts.push(`"${filters.query || filters.search}"`);
|
||||
if (filters.category || filters.type) parts.push(filters.category || filters.type);
|
||||
if (filters.brand || filters.brandName) parts.push(filters.brand || filters.brandName);
|
||||
if (filters.strainType) parts.push(filters.strainType);
|
||||
if (filters.maxPrice) parts.push(`Under $${filters.maxPrice}`);
|
||||
if (filters.minThc) parts.push(`${filters.minThc}%+ THC`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' - ') : 'All Products';
|
||||
}
|
||||
|
||||
// Default export
|
||||
const consumerApi = {
|
||||
// Favorites
|
||||
getFavorites,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
removeFavoriteByProduct,
|
||||
checkFavorite,
|
||||
// Alerts
|
||||
getAlerts,
|
||||
getAlertStats,
|
||||
createPriceAlert,
|
||||
createStockAlert,
|
||||
createBrandCategoryAlert,
|
||||
updateAlert,
|
||||
toggleAlert,
|
||||
deleteAlert,
|
||||
// Saved Searches
|
||||
getSavedSearches,
|
||||
createSavedSearch,
|
||||
updateSavedSearch,
|
||||
deleteSavedSearch,
|
||||
runSavedSearch,
|
||||
// Helpers
|
||||
generateSearchName,
|
||||
};
|
||||
|
||||
export default consumerApi;
|
||||
315
findagram/frontend/src/components/findagram/AuthModal.jsx
Normal file
315
findagram/frontend/src/components/findagram/AuthModal.jsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/**
|
||||
* AuthModal - Login/Signup modal for Findagram
|
||||
*
|
||||
* Shows when user tries to:
|
||||
* - Favorite a product
|
||||
* - Set a price alert
|
||||
* - Save a search
|
||||
* - Access dashboard features
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import { X, Mail, Lock, User, Phone, MapPin, Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const AuthModal = () => {
|
||||
const {
|
||||
showAuthModal,
|
||||
authModalMode,
|
||||
setAuthModalMode,
|
||||
closeAuthModal,
|
||||
login,
|
||||
register,
|
||||
} = useAuth();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
state: '',
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
if (!showAuthModal) return null;
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (authModalMode === 'login') {
|
||||
await login(formData.email, formData.password);
|
||||
} else {
|
||||
// Validate signup fields
|
||||
if (!formData.firstName || !formData.lastName) {
|
||||
throw new Error('First and last name are required');
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
throw new Error('Password must be at least 6 characters');
|
||||
}
|
||||
await register(formData);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const switchMode = () => {
|
||||
setAuthModalMode(authModalMode === 'login' ? 'signup' : 'login');
|
||||
setError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={closeAuthModal}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<Card className="relative w-full max-w-md bg-white shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={closeAuthModal}
|
||||
className="absolute top-4 right-4 p-1 rounded-full hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
|
||||
<CardHeader className="text-center pb-2">
|
||||
<div className="mx-auto w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center mb-4">
|
||||
<User className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<CardTitle>
|
||||
{authModalMode === 'login' ? 'Welcome Back' : 'Create Account'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{authModalMode === 'login'
|
||||
? 'Sign in to save favorites and set price alerts'
|
||||
: 'Join Findagram to track products and get notified of deals'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signup only fields */}
|
||||
{authModalMode === 'signup' && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
First Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="John"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Doe"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder={authModalMode === 'signup' ? 'Min 6 characters' : 'Your password'}
|
||||
required
|
||||
minLength={authModalMode === 'signup' ? 6 : undefined}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signup only: Phone & Location */}
|
||||
{authModalMode === 'signup' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Phone <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">For SMS alerts about price drops</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
City <span className="text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
placeholder="Phoenix"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
State
|
||||
</label>
|
||||
<select
|
||||
name="state"
|
||||
value={formData.state}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
<option value="AZ">Arizona</option>
|
||||
<option value="CA">California</option>
|
||||
<option value="CO">Colorado</option>
|
||||
<option value="MI">Michigan</option>
|
||||
<option value="NV">Nevada</option>
|
||||
<option value="OR">Oregon</option>
|
||||
<option value="WA">Washington</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white py-2.5"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{authModalMode === 'login' ? 'Signing in...' : 'Creating account...'}
|
||||
</>
|
||||
) : (
|
||||
authModalMode === 'login' ? 'Sign In' : 'Create Account'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Switch mode link */}
|
||||
<div className="text-center text-sm text-gray-600">
|
||||
{authModalMode === 'login' ? (
|
||||
<>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchMode}
|
||||
className="text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchMode}
|
||||
className="text-purple-600 hover:text-purple-700 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthModal;
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import {
|
||||
@@ -27,7 +28,8 @@ import {
|
||||
Store,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
const Header = () => {
|
||||
const { isAuthenticated, user, logout, openAuthModal } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const location = useLocation();
|
||||
@@ -99,7 +101,7 @@ const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
|
||||
{/* Right side actions */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{isLoggedIn ? (
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{/* Favorites */}
|
||||
<Link to="/dashboard/favorites" className="hidden sm:block">
|
||||
@@ -121,9 +123,9 @@ const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10 border-2 border-primary">
|
||||
<AvatarImage src={user?.avatar} alt={user?.name} />
|
||||
<AvatarImage src={user?.avatar} alt={user?.firstName} />
|
||||
<AvatarFallback className="bg-primary text-white">
|
||||
{user?.name?.charAt(0) || 'U'}
|
||||
{user?.firstName?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
@@ -131,9 +133,11 @@ const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
<DropdownMenuContent className="w-56" align="end" forceMount>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{user?.name || 'User'}</p>
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
<p className="text-xs leading-none text-muted-foreground">
|
||||
{user?.email || 'user@example.com'}
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
@@ -169,7 +173,10 @@ const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<DropdownMenuItem
|
||||
className="text-red-600 cursor-pointer"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
@@ -178,16 +185,19 @@ const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="hidden sm:block">
|
||||
<Button variant="ghost" className="text-gray-600">
|
||||
Log in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button className="gradient-purple text-white hover:opacity-90">
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hidden sm:block text-gray-600"
|
||||
onClick={() => openAuthModal('login')}
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
<Button
|
||||
className="gradient-purple text-white hover:opacity-90"
|
||||
onClick={() => openAuthModal('signup')}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -241,7 +251,7 @@ const Header = ({ isLoggedIn = false, user = null }) => {
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
{isLoggedIn && (
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 my-2" />
|
||||
<Link
|
||||
|
||||
@@ -1,16 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { Card, CardContent } from '../ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Heart, Star, MapPin, TrendingDown } from 'lucide-react';
|
||||
import { Heart, Star, MapPin, TrendingDown, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { addFavorite, removeFavoriteByProduct, checkFavorite } from '../../api/consumer';
|
||||
import { trackProductClick } from '../../api/client';
|
||||
|
||||
const ProductCard = ({
|
||||
product,
|
||||
onFavorite,
|
||||
isFavorite = false,
|
||||
showDispensaryCount = true
|
||||
onFavoriteChange,
|
||||
initialIsFavorite,
|
||||
showDispensaryCount = true,
|
||||
pageType = 'browse'
|
||||
}) => {
|
||||
const location = useLocation();
|
||||
const { isAuthenticated, requireAuth, authFetch } = useAuth();
|
||||
const [isFavorite, setIsFavorite] = useState(initialIsFavorite || false);
|
||||
const [favoriteLoading, setFavoriteLoading] = useState(false);
|
||||
|
||||
// Check favorite status on mount if authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && product?.id && initialIsFavorite === undefined) {
|
||||
checkFavorite(authFetch, product.id)
|
||||
.then(data => setIsFavorite(data.isFavorited))
|
||||
.catch(() => {}); // Ignore errors
|
||||
}
|
||||
}, [isAuthenticated, product?.id, authFetch, initialIsFavorite]);
|
||||
|
||||
const handleFavoriteClick = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// If not authenticated, show auth modal with pending action
|
||||
if (!requireAuth(() => handleFavoriteClick({ preventDefault: () => {}, stopPropagation: () => {} }))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFavoriteLoading(true);
|
||||
try {
|
||||
if (isFavorite) {
|
||||
await removeFavoriteByProduct(authFetch, product.id);
|
||||
setIsFavorite(false);
|
||||
} else {
|
||||
await addFavorite(authFetch, product.id, product.dispensaryId);
|
||||
setIsFavorite(true);
|
||||
}
|
||||
onFavoriteChange?.(product.id, !isFavorite);
|
||||
} catch (error) {
|
||||
console.error('Failed to update favorite:', error);
|
||||
} finally {
|
||||
setFavoriteLoading(false);
|
||||
}
|
||||
};
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
@@ -35,11 +78,24 @@ const ProductCard = ({
|
||||
hybrid: 'bg-green-100 text-green-800',
|
||||
};
|
||||
|
||||
const savings = onSale && salePrice ? ((price - salePrice) / price * 100).toFixed(0) : 0;
|
||||
const savings = onSale && salePrice && price ? ((price - salePrice) / price * 100).toFixed(0) : 0;
|
||||
|
||||
// Track product click
|
||||
const handleProductClick = () => {
|
||||
trackProductClick({
|
||||
productId: id,
|
||||
storeId: product.dispensaryId,
|
||||
brandId: brand,
|
||||
dispensaryName: product.storeName,
|
||||
action: 'open_product',
|
||||
source: 'findagram',
|
||||
pageType: pageType || location.pathname.split('/')[1] || 'home',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="product-card group overflow-hidden">
|
||||
<Link to={`/products/${id}`}>
|
||||
<Link to={`/products/${id}`} onClick={handleProductClick}>
|
||||
{/* Image Container */}
|
||||
<div className="relative aspect-square overflow-hidden bg-gray-100">
|
||||
<img
|
||||
@@ -70,13 +126,14 @@ const ProductCard = ({
|
||||
className={`absolute top-3 right-3 h-8 w-8 rounded-full bg-white/80 hover:bg-white ${
|
||||
isFavorite ? 'text-red-500' : 'text-gray-400'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFavorite?.(id);
|
||||
}}
|
||||
onClick={handleFavoriteClick}
|
||||
disabled={favoriteLoading}
|
||||
>
|
||||
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
|
||||
{favoriteLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -88,7 +145,7 @@ const ProductCard = ({
|
||||
</p>
|
||||
|
||||
{/* Product Name */}
|
||||
<Link to={`/products/${id}`}>
|
||||
<Link to={`/products/${id}`} onClick={handleProductClick}>
|
||||
<h3 className="font-semibold text-gray-900 line-clamp-2 hover:text-primary transition-colors mb-2">
|
||||
{name}
|
||||
</h3>
|
||||
@@ -124,27 +181,31 @@ const ProductCard = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-2 mb-3">
|
||||
{onSale && salePrice ? (
|
||||
<>
|
||||
<span className="text-lg font-bold text-pink-600">
|
||||
${salePrice.toFixed(2)}
|
||||
{/* Price - only show if we have price data */}
|
||||
{(price != null || salePrice != null || priceRange != null) && (
|
||||
<div className="flex items-baseline gap-2 mb-3">
|
||||
{onSale && salePrice ? (
|
||||
<>
|
||||
<span className="text-lg font-bold text-pink-600">
|
||||
${salePrice.toFixed(2)}
|
||||
</span>
|
||||
{price && (
|
||||
<span className="text-sm text-gray-400 line-through">
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : priceRange && priceRange.min != null && priceRange.max != null ? (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
${priceRange.min.toFixed(2)} - ${priceRange.max.toFixed(2)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-400 line-through">
|
||||
) : price != null ? (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
</>
|
||||
) : priceRange ? (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
${priceRange.min.toFixed(2)} - ${priceRange.max.toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-lg font-bold text-gray-900">
|
||||
${price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dispensary Count */}
|
||||
{showDispensaryCount && dispensaries.length > 0 && (
|
||||
|
||||
258
findagram/frontend/src/context/AuthContext.js
Normal file
258
findagram/frontend/src/context/AuthContext.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* AuthContext - Global authentication state for Findagram
|
||||
*
|
||||
* Manages user login state, JWT token, and provides auth methods.
|
||||
* Persists auth state in localStorage for session continuity.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || '';
|
||||
const STORAGE_KEY = 'findagram_auth';
|
||||
const DOMAIN = 'findagram.co';
|
||||
|
||||
/**
|
||||
* AuthProvider component - wrap your app with this
|
||||
*/
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [authModalMode, setAuthModalMode] = useState('login'); // 'login' or 'signup'
|
||||
const [pendingAction, setPendingAction] = useState(null); // Action to perform after login
|
||||
|
||||
// Load auth state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const { user: storedUser, token: storedToken } = JSON.parse(stored);
|
||||
setUser(storedUser);
|
||||
setToken(storedToken);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse stored auth:', e);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Save auth state to localStorage when it changes
|
||||
useEffect(() => {
|
||||
if (user && token) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ user, token }));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}, [user, token]);
|
||||
|
||||
/**
|
||||
* Make authenticated API request
|
||||
*/
|
||||
const authFetch = useCallback(async (endpoint, options = {}) => {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
// Handle 401 - token expired
|
||||
if (response.status === 401) {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
throw new Error('Session expired. Please log in again.');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Request failed' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}, [token]);
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
const register = useCallback(async ({ firstName, lastName, email, password, phone, city, state }) => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/consumer/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password,
|
||||
phone,
|
||||
city,
|
||||
state,
|
||||
domain: DOMAIN,
|
||||
notificationPreference: phone ? 'both' : 'email',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Registration failed');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setToken(data.token);
|
||||
setShowAuthModal(false);
|
||||
|
||||
// Execute pending action if any
|
||||
if (pendingAction) {
|
||||
setTimeout(() => {
|
||||
pendingAction();
|
||||
setPendingAction(null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [pendingAction]);
|
||||
|
||||
/**
|
||||
* Login user
|
||||
*/
|
||||
const login = useCallback(async (email, password) => {
|
||||
const response = await fetch(`${API_BASE_URL}/api/consumer/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
domain: DOMAIN,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Login failed');
|
||||
}
|
||||
|
||||
setUser(data.user);
|
||||
setToken(data.token);
|
||||
setShowAuthModal(false);
|
||||
|
||||
// Execute pending action if any
|
||||
if (pendingAction) {
|
||||
setTimeout(() => {
|
||||
pendingAction();
|
||||
setPendingAction(null);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return data;
|
||||
}, [pendingAction]);
|
||||
|
||||
/**
|
||||
* Logout user
|
||||
*/
|
||||
const logout = useCallback(() => {
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
const updateProfile = useCallback(async (updates) => {
|
||||
const data = await authFetch('/api/consumer/auth/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
|
||||
// Refresh user data
|
||||
const meData = await authFetch('/api/consumer/auth/me');
|
||||
setUser(meData.user);
|
||||
|
||||
return data;
|
||||
}, [authFetch]);
|
||||
|
||||
/**
|
||||
* Require auth - shows modal if not logged in
|
||||
* Returns true if authenticated, false if modal was shown
|
||||
*
|
||||
* @param {Function} action - Optional action to perform after successful auth
|
||||
*/
|
||||
const requireAuth = useCallback((action = null) => {
|
||||
if (user && token) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setPendingAction(() => action);
|
||||
setAuthModalMode('login');
|
||||
setShowAuthModal(true);
|
||||
return false;
|
||||
}, [user, token]);
|
||||
|
||||
/**
|
||||
* Open auth modal in specific mode
|
||||
*/
|
||||
const openAuthModal = useCallback((mode = 'login') => {
|
||||
setAuthModalMode(mode);
|
||||
setShowAuthModal(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Close auth modal
|
||||
*/
|
||||
const closeAuthModal = useCallback(() => {
|
||||
setShowAuthModal(false);
|
||||
setPendingAction(null);
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
isAuthenticated: !!user && !!token,
|
||||
showAuthModal,
|
||||
authModalMode,
|
||||
|
||||
// Auth methods
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
updateProfile,
|
||||
authFetch,
|
||||
|
||||
// Modal control
|
||||
requireAuth,
|
||||
openAuthModal,
|
||||
closeAuthModal,
|
||||
setAuthModalMode,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use auth context
|
||||
*/
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export default AuthContext;
|
||||
314
findagram/frontend/src/hooks/useGeolocation.js
Normal file
314
findagram/frontend/src/hooks/useGeolocation.js
Normal file
@@ -0,0 +1,314 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
// Default location: Phoenix, AZ (fallback if all else fails)
|
||||
const DEFAULT_LOCATION = {
|
||||
lat: 33.4484,
|
||||
lng: -112.0740,
|
||||
city: 'Phoenix',
|
||||
state: 'AZ'
|
||||
};
|
||||
|
||||
const LOCATION_STORAGE_KEY = 'findagram_location';
|
||||
const SESSION_ID_KEY = 'findagram_session_id';
|
||||
const API_BASE_URL = process.env.REACT_APP_API_URL || '';
|
||||
|
||||
/**
|
||||
* Get or create session ID
|
||||
*/
|
||||
function getSessionId() {
|
||||
let sessionId = sessionStorage.getItem(SESSION_ID_KEY);
|
||||
if (!sessionId) {
|
||||
sessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
sessionStorage.setItem(SESSION_ID_KEY, sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached location from sessionStorage
|
||||
*/
|
||||
function getCachedLocation() {
|
||||
try {
|
||||
const cached = sessionStorage.getItem(LOCATION_STORAGE_KEY);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error reading cached location:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save location to sessionStorage
|
||||
*/
|
||||
function cacheLocation(location) {
|
||||
try {
|
||||
sessionStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(location));
|
||||
} catch (err) {
|
||||
console.error('Error caching location:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track visitor and get location from our backend API
|
||||
* This logs the visit for analytics and returns location from IP
|
||||
*/
|
||||
async function trackVisitorAndGetLocation() {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/v1/visitor/track`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
domain: 'findagram.co',
|
||||
page_path: window.location.pathname,
|
||||
session_id: getSessionId(),
|
||||
referrer: document.referrer || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.location) {
|
||||
return {
|
||||
lat: data.location.lat,
|
||||
lng: data.location.lng,
|
||||
city: data.location.city,
|
||||
state: data.location.state,
|
||||
stateCode: data.location.stateCode,
|
||||
source: 'api'
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Visitor tracking error:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for getting user's geolocation
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.autoRequest - Whether to request location automatically on mount
|
||||
* @param {boolean} options.useIPFallback - Whether to use IP geolocation as fallback (default: true)
|
||||
* @param {Object} options.defaultLocation - Default location if all methods fail
|
||||
* @returns {Object} { location, loading, error, requestLocation, hasPermission }
|
||||
*/
|
||||
export function useGeolocation(options = {}) {
|
||||
const {
|
||||
autoRequest = false,
|
||||
useIPFallback = true,
|
||||
defaultLocation = DEFAULT_LOCATION
|
||||
} = options;
|
||||
|
||||
const [location, setLocation] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [hasPermission, setHasPermission] = useState(null);
|
||||
const [locationSource, setLocationSource] = useState(null); // 'gps', 'ip', or 'default'
|
||||
|
||||
// Try IP geolocation first (no permission needed)
|
||||
const getIPLocation = useCallback(async () => {
|
||||
if (!useIPFallback) return null;
|
||||
|
||||
const ipLoc = await getLocationFromIP();
|
||||
if (ipLoc) {
|
||||
setLocation(ipLoc);
|
||||
setLocationSource('ip');
|
||||
return ipLoc;
|
||||
}
|
||||
return null;
|
||||
}, [useIPFallback]);
|
||||
|
||||
// Request precise GPS location (requires permission)
|
||||
const requestLocation = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// First try browser geolocation
|
||||
if (navigator.geolocation) {
|
||||
return new Promise(async (resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude } = position.coords;
|
||||
const loc = { lat: latitude, lng: longitude, source: 'gps' };
|
||||
setLocation(loc);
|
||||
setLocationSource('gps');
|
||||
setHasPermission(true);
|
||||
setLoading(false);
|
||||
resolve(loc);
|
||||
},
|
||||
async (err) => {
|
||||
console.error('Geolocation error:', err);
|
||||
|
||||
if (err.code === err.PERMISSION_DENIED) {
|
||||
setHasPermission(false);
|
||||
}
|
||||
|
||||
// Fall back to IP geolocation
|
||||
if (useIPFallback) {
|
||||
const ipLoc = await getLocationFromIP();
|
||||
if (ipLoc) {
|
||||
setLocation(ipLoc);
|
||||
setLocationSource('ip');
|
||||
setLoading(false);
|
||||
resolve(ipLoc);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: default location
|
||||
setError('Unable to determine location');
|
||||
setLocation(defaultLocation);
|
||||
setLocationSource('default');
|
||||
setLoading(false);
|
||||
resolve(defaultLocation);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 5000,
|
||||
maximumAge: 600000 // Cache for 10 minutes
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// No browser geolocation, try IP
|
||||
if (useIPFallback) {
|
||||
const ipLoc = await getLocationFromIP();
|
||||
if (ipLoc) {
|
||||
setLocation(ipLoc);
|
||||
setLocationSource('ip');
|
||||
setLoading(false);
|
||||
return ipLoc;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to default
|
||||
setLocation(defaultLocation);
|
||||
setLocationSource('default');
|
||||
setLoading(false);
|
||||
return defaultLocation;
|
||||
}, [defaultLocation, useIPFallback]);
|
||||
|
||||
// Auto-request location on mount if enabled
|
||||
useEffect(() => {
|
||||
if (autoRequest) {
|
||||
const init = async () => {
|
||||
// Check for cached location first
|
||||
const cached = getCachedLocation();
|
||||
if (cached) {
|
||||
setLocation(cached);
|
||||
setLocationSource(cached.source || 'api');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// Track visitor and get location from our backend API
|
||||
const apiLoc = await trackVisitorAndGetLocation();
|
||||
if (apiLoc) {
|
||||
setLocation(apiLoc);
|
||||
setLocationSource('api');
|
||||
cacheLocation(apiLoc); // Save for session
|
||||
} else {
|
||||
// Fallback to default
|
||||
setLocation(defaultLocation);
|
||||
setLocationSource('default');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
init();
|
||||
}
|
||||
}, [autoRequest, defaultLocation]);
|
||||
|
||||
return {
|
||||
location,
|
||||
loading,
|
||||
error,
|
||||
requestLocation,
|
||||
hasPermission,
|
||||
locationSource,
|
||||
isDefault: locationSource === 'default',
|
||||
isFromIP: locationSource === 'ip',
|
||||
isFromGPS: locationSource === 'gps'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance between two points using Haversine formula
|
||||
*
|
||||
* @param {number} lat1 - First point latitude
|
||||
* @param {number} lng1 - First point longitude
|
||||
* @param {number} lat2 - Second point latitude
|
||||
* @param {number} lng2 - Second point longitude
|
||||
* @returns {number} Distance in miles
|
||||
*/
|
||||
export function calculateDistance(lat1, lng1, lat2, lng2) {
|
||||
const R = 3959; // Earth's radius in miles
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLng = toRad(lng2 - lng1);
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
function toRad(deg) {
|
||||
return deg * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort items by distance from a location
|
||||
*
|
||||
* @param {Array} items - Array of items with location data
|
||||
* @param {Object} userLocation - User's location { lat, lng }
|
||||
* @param {Function} getItemLocation - Function to extract lat/lng from item
|
||||
* @returns {Array} Items sorted by distance with distance property added
|
||||
*/
|
||||
export function sortByDistance(items, userLocation, getItemLocation = (item) => item.location) {
|
||||
if (!userLocation || !items?.length) return items;
|
||||
|
||||
return items
|
||||
.map(item => {
|
||||
const itemLoc = getItemLocation(item);
|
||||
if (!itemLoc?.latitude || !itemLoc?.longitude) {
|
||||
return { ...item, distance: null };
|
||||
}
|
||||
const distance = calculateDistance(
|
||||
userLocation.lat,
|
||||
userLocation.lng,
|
||||
itemLoc.latitude,
|
||||
itemLoc.longitude
|
||||
);
|
||||
return { ...item, distance: Math.round(distance * 10) / 10 };
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.distance === null) return 1;
|
||||
if (b.distance === null) return -1;
|
||||
return a.distance - b.distance;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items within a radius from user location
|
||||
*
|
||||
* @param {Array} items - Array of items with location data
|
||||
* @param {Object} userLocation - User's location { lat, lng }
|
||||
* @param {number} radiusMiles - Max distance in miles
|
||||
* @param {Function} getItemLocation - Function to extract lat/lng from item
|
||||
* @returns {Array} Items within radius, sorted by distance
|
||||
*/
|
||||
export function filterByRadius(items, userLocation, radiusMiles = 50, getItemLocation = (item) => item.location) {
|
||||
const sorted = sortByDistance(items, userLocation, getItemLocation);
|
||||
return sorted.filter(item => item.distance !== null && item.distance <= radiusMiles);
|
||||
}
|
||||
|
||||
export default useGeolocation;
|
||||
363
findagram/frontend/src/lib/storage.js
Normal file
363
findagram/frontend/src/lib/storage.js
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* localStorage helpers for user data persistence
|
||||
*
|
||||
* Manages favorites, price alerts, and saved searches without requiring authentication.
|
||||
* All data is stored locally in the browser.
|
||||
*/
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
FAVORITES: 'findagram_favorites',
|
||||
ALERTS: 'findagram_alerts',
|
||||
SAVED_SEARCHES: 'findagram_saved_searches',
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// FAVORITES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get all favorite product IDs
|
||||
* @returns {number[]} Array of product IDs
|
||||
*/
|
||||
export function getFavorites() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (e) {
|
||||
console.error('Error reading favorites:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a product is favorited
|
||||
* @param {number} productId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isFavorite(productId) {
|
||||
const favorites = getFavorites();
|
||||
return favorites.includes(productId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a product to favorites
|
||||
* @param {number} productId
|
||||
*/
|
||||
export function addFavorite(productId) {
|
||||
const favorites = getFavorites();
|
||||
if (!favorites.includes(productId)) {
|
||||
favorites.push(productId);
|
||||
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a product from favorites
|
||||
* @param {number} productId
|
||||
*/
|
||||
export function removeFavorite(productId) {
|
||||
const favorites = getFavorites();
|
||||
const updated = favorites.filter(id => id !== productId);
|
||||
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(updated));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a product's favorite status
|
||||
* @param {number} productId
|
||||
* @returns {boolean} New favorite status
|
||||
*/
|
||||
export function toggleFavorite(productId) {
|
||||
if (isFavorite(productId)) {
|
||||
removeFavorite(productId);
|
||||
return false;
|
||||
} else {
|
||||
addFavorite(productId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all favorites
|
||||
*/
|
||||
export function clearFavorites() {
|
||||
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify([]));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PRICE ALERTS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* @typedef {Object} PriceAlert
|
||||
* @property {string} id - Unique alert ID
|
||||
* @property {number} productId - Product ID to track
|
||||
* @property {string} productName - Product name (for display when offline)
|
||||
* @property {string} productImage - Product image URL
|
||||
* @property {string} brandName - Brand name
|
||||
* @property {number} targetPrice - Target price to alert at
|
||||
* @property {number} originalPrice - Price when alert was created
|
||||
* @property {boolean} active - Whether alert is active
|
||||
* @property {string} createdAt - ISO date string
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all price alerts
|
||||
* @returns {PriceAlert[]}
|
||||
*/
|
||||
export function getAlerts() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.ALERTS);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (e) {
|
||||
console.error('Error reading alerts:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get alert for a specific product
|
||||
* @param {number} productId
|
||||
* @returns {PriceAlert|null}
|
||||
*/
|
||||
export function getAlertForProduct(productId) {
|
||||
const alerts = getAlerts();
|
||||
return alerts.find(a => a.productId === productId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new price alert
|
||||
* @param {Object} params
|
||||
* @param {number} params.productId
|
||||
* @param {string} params.productName
|
||||
* @param {string} params.productImage
|
||||
* @param {string} params.brandName
|
||||
* @param {number} params.targetPrice
|
||||
* @param {number} params.originalPrice
|
||||
* @returns {PriceAlert}
|
||||
*/
|
||||
export function createAlert({ productId, productName, productImage, brandName, targetPrice, originalPrice }) {
|
||||
const alerts = getAlerts();
|
||||
|
||||
// Check if alert already exists for this product
|
||||
const existingIndex = alerts.findIndex(a => a.productId === productId);
|
||||
|
||||
const alert = {
|
||||
id: existingIndex >= 0 ? alerts[existingIndex].id : `alert_${Date.now()}`,
|
||||
productId,
|
||||
productName,
|
||||
productImage,
|
||||
brandName,
|
||||
targetPrice,
|
||||
originalPrice,
|
||||
active: true,
|
||||
createdAt: existingIndex >= 0 ? alerts[existingIndex].createdAt : new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
alerts[existingIndex] = alert;
|
||||
} else {
|
||||
alerts.push(alert);
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
|
||||
return alert;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing alert
|
||||
* @param {string} alertId
|
||||
* @param {Partial<PriceAlert>} updates
|
||||
*/
|
||||
export function updateAlert(alertId, updates) {
|
||||
const alerts = getAlerts();
|
||||
const index = alerts.findIndex(a => a.id === alertId);
|
||||
if (index >= 0) {
|
||||
alerts[index] = { ...alerts[index], ...updates };
|
||||
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle alert active status
|
||||
* @param {string} alertId
|
||||
* @returns {boolean} New active status
|
||||
*/
|
||||
export function toggleAlertActive(alertId) {
|
||||
const alerts = getAlerts();
|
||||
const alert = alerts.find(a => a.id === alertId);
|
||||
if (alert) {
|
||||
alert.active = !alert.active;
|
||||
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
|
||||
return alert.active;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an alert
|
||||
* @param {string} alertId
|
||||
*/
|
||||
export function deleteAlert(alertId) {
|
||||
const alerts = getAlerts();
|
||||
const updated = alerts.filter(a => a.id !== alertId);
|
||||
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(updated));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all alerts
|
||||
*/
|
||||
export function clearAlerts() {
|
||||
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify([]));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SAVED SEARCHES
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* @typedef {Object} SavedSearch
|
||||
* @property {string} id - Unique search ID
|
||||
* @property {string} name - User-defined name for the search
|
||||
* @property {Object} filters - Search filter parameters
|
||||
* @property {string} [filters.search] - Search term
|
||||
* @property {string} [filters.type] - Category type
|
||||
* @property {string} [filters.brandName] - Brand filter
|
||||
* @property {string} [filters.strainType] - Strain type filter
|
||||
* @property {number} [filters.priceMax] - Max price filter
|
||||
* @property {number} [filters.thcMin] - Min THC filter
|
||||
* @property {string} createdAt - ISO date string
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get all saved searches
|
||||
* @returns {SavedSearch[]}
|
||||
*/
|
||||
export function getSavedSearches() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEYS.SAVED_SEARCHES);
|
||||
return data ? JSON.parse(data) : [];
|
||||
} catch (e) {
|
||||
console.error('Error reading saved searches:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new saved search
|
||||
* @param {Object} params
|
||||
* @param {string} params.name - Display name for the search
|
||||
* @param {Object} params.filters - Search filters
|
||||
* @returns {SavedSearch}
|
||||
*/
|
||||
export function createSavedSearch({ name, filters }) {
|
||||
const searches = getSavedSearches();
|
||||
|
||||
const search = {
|
||||
id: `search_${Date.now()}`,
|
||||
name,
|
||||
filters,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
searches.push(search);
|
||||
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(searches));
|
||||
return search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a saved search
|
||||
* @param {string} searchId
|
||||
* @param {Partial<SavedSearch>} updates
|
||||
*/
|
||||
export function updateSavedSearch(searchId, updates) {
|
||||
const searches = getSavedSearches();
|
||||
const index = searches.findIndex(s => s.id === searchId);
|
||||
if (index >= 0) {
|
||||
searches[index] = { ...searches[index], ...updates };
|
||||
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(searches));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a saved search
|
||||
* @param {string} searchId
|
||||
*/
|
||||
export function deleteSavedSearch(searchId) {
|
||||
const searches = getSavedSearches();
|
||||
const updated = searches.filter(s => s.id !== searchId);
|
||||
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(updated));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all saved searches
|
||||
*/
|
||||
export function clearSavedSearches() {
|
||||
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify([]));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// UTILITY FUNCTIONS
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Build a URL with search params from filters
|
||||
* @param {Object} filters
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSearchUrl(filters) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
return `/products?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a name for a search based on its filters
|
||||
* @param {Object} filters
|
||||
* @returns {string}
|
||||
*/
|
||||
export function generateSearchName(filters) {
|
||||
const parts = [];
|
||||
|
||||
if (filters.search) parts.push(`"${filters.search}"`);
|
||||
if (filters.type) parts.push(filters.type);
|
||||
if (filters.brandName) parts.push(filters.brandName);
|
||||
if (filters.strainType) parts.push(filters.strainType);
|
||||
if (filters.priceMax) parts.push(`Under $${filters.priceMax}`);
|
||||
if (filters.thcMin) parts.push(`${filters.thcMin}%+ THC`);
|
||||
|
||||
return parts.length > 0 ? parts.join(' - ') : 'All Products';
|
||||
}
|
||||
|
||||
// Default export
|
||||
const storage = {
|
||||
// Favorites
|
||||
getFavorites,
|
||||
isFavorite,
|
||||
addFavorite,
|
||||
removeFavorite,
|
||||
toggleFavorite,
|
||||
clearFavorites,
|
||||
// Alerts
|
||||
getAlerts,
|
||||
getAlertForProduct,
|
||||
createAlert,
|
||||
updateAlert,
|
||||
toggleAlertActive,
|
||||
deleteAlert,
|
||||
clearAlerts,
|
||||
// Saved Searches
|
||||
getSavedSearches,
|
||||
createSavedSearch,
|
||||
updateSavedSearch,
|
||||
deleteSavedSearch,
|
||||
clearSavedSearches,
|
||||
// Utilities
|
||||
buildSearchUrl,
|
||||
generateSearchName,
|
||||
};
|
||||
|
||||
export default storage;
|
||||
@@ -1,28 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { mockAlerts, mockProducts } from '../../mockData';
|
||||
import { Bell, Trash2, Pause, Play, TrendingDown } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { getAlerts, toggleAlert, deleteAlert } from '../../api/consumer';
|
||||
import { Bell, Trash2, Pause, Play, TrendingDown, Loader2 } from 'lucide-react';
|
||||
|
||||
const Alerts = () => {
|
||||
const [alerts, setAlerts] = useState(mockAlerts);
|
||||
const { isAuthenticated, authFetch, requireAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const toggleAlert = (alertId) => {
|
||||
setAlerts((prev) =>
|
||||
prev.map((alert) =>
|
||||
alert.id === alertId ? { ...alert, active: !alert.active } : alert
|
||||
)
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [togglingId, setTogglingId] = useState(null);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
|
||||
// Redirect to home if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
requireAuth(() => navigate('/dashboard/alerts'));
|
||||
}
|
||||
}, [isAuthenticated, requireAuth, navigate]);
|
||||
|
||||
// Fetch alerts
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const fetchAlerts = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await getAlerts(authFetch);
|
||||
setAlerts(data.alerts || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAlerts();
|
||||
}, [isAuthenticated, authFetch]);
|
||||
|
||||
const handleToggleAlert = async (alertId) => {
|
||||
setTogglingId(alertId);
|
||||
try {
|
||||
const result = await toggleAlert(authFetch, alertId);
|
||||
setAlerts(prev =>
|
||||
prev.map(a => a.id === alertId ? { ...a, isActive: result.isActive } : a)
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAlert = async (alertId) => {
|
||||
setDeletingId(alertId);
|
||||
try {
|
||||
await deleteAlert(authFetch, alertId);
|
||||
setAlerts(prev => prev.filter(a => a.id !== alertId));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const deleteAlert = (alertId) => {
|
||||
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
|
||||
};
|
||||
|
||||
const activeAlerts = alerts.filter((a) => a.active);
|
||||
const pausedAlerts = alerts.filter((a) => !a.active);
|
||||
const activeAlerts = alerts.filter(a => a.isActive);
|
||||
const pausedAlerts = alerts.filter(a => !a.isActive);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@@ -50,6 +112,12 @@ const Alerts = () => {
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alerts.length > 0 ? (
|
||||
<div className="space-y-8">
|
||||
{/* Active Alerts */}
|
||||
@@ -61,40 +129,46 @@ const Alerts = () => {
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{activeAlerts.map((alert) => {
|
||||
const product = mockProducts.find((p) => p.id === alert.productId);
|
||||
const priceDiff = product ? product.price - alert.targetPrice : 0;
|
||||
const isTriggered = priceDiff <= 0;
|
||||
const isTriggered = alert.isTriggered;
|
||||
|
||||
return (
|
||||
<Card key={alert.id} className={isTriggered ? 'border-green-500 bg-green-50' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link to={`/products/${product?.id}`}>
|
||||
<Link to={`/products/${alert.productId}`}>
|
||||
<img
|
||||
src={product?.image || '/placeholder-product.jpg'}
|
||||
alt={product?.name}
|
||||
src={alert.productImage || '/placeholder-product.jpg'}
|
||||
alt={alert.productName}
|
||||
className="w-16 h-16 rounded-lg object-cover"
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to={`/products/${product?.id}`}
|
||||
to={`/products/${alert.productId}`}
|
||||
className="font-medium text-gray-900 hover:text-primary truncate block"
|
||||
>
|
||||
{product?.name}
|
||||
{alert.productName}
|
||||
</Link>
|
||||
<p className="text-sm text-gray-500">{product?.brand}</p>
|
||||
<p className="text-sm text-gray-500">{alert.productBrand}</p>
|
||||
<div className="flex items-center gap-4 mt-1">
|
||||
<span className="text-sm">
|
||||
Current: <span className="font-medium">${product?.price.toFixed(2)}</span>
|
||||
Current:{' '}
|
||||
<span className="font-medium">
|
||||
{alert.currentPrice
|
||||
? `$${parseFloat(alert.currentPrice).toFixed(2)}`
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm">
|
||||
Target: <span className="font-medium text-primary">${alert.targetPrice.toFixed(2)}</span>
|
||||
Target:{' '}
|
||||
<span className="font-medium text-primary">
|
||||
${parseFloat(alert.targetPrice).toFixed(2)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isTriggered && (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
<Badge className="bg-green-500 text-white flex items-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
Price Dropped!
|
||||
</Badge>
|
||||
@@ -103,19 +177,29 @@ const Alerts = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleAlert(alert.id)}
|
||||
onClick={() => handleToggleAlert(alert.id)}
|
||||
disabled={togglingId === alert.id}
|
||||
title="Pause alert"
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
{togglingId === alert.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Pause className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteAlert(alert.id)}
|
||||
onClick={() => handleDeleteAlert(alert.id)}
|
||||
disabled={deletingId === alert.id}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Delete alert"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deletingId === alert.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,52 +219,61 @@ const Alerts = () => {
|
||||
Paused Alerts ({pausedAlerts.length})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{pausedAlerts.map((alert) => {
|
||||
const product = mockProducts.find((p) => p.id === alert.productId);
|
||||
|
||||
return (
|
||||
<Card key={alert.id} className="opacity-75">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={product?.image || '/placeholder-product.jpg'}
|
||||
alt={product?.name}
|
||||
className="w-16 h-16 rounded-lg object-cover grayscale"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{product?.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{product?.brand}</p>
|
||||
<span className="text-sm">
|
||||
Target: <span className="font-medium">${alert.targetPrice.toFixed(2)}</span>
|
||||
{pausedAlerts.map((alert) => (
|
||||
<Card key={alert.id} className="opacity-75">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={alert.productImage || '/placeholder-product.jpg'}
|
||||
alt={alert.productName}
|
||||
className="w-16 h-16 rounded-lg object-cover grayscale"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{alert.productName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{alert.productBrand}</p>
|
||||
<span className="text-sm">
|
||||
Target:{' '}
|
||||
<span className="font-medium">
|
||||
${parseFloat(alert.targetPrice).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">Paused</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => toggleAlert(alert.id)}
|
||||
title="Resume alert"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteAlert(alert.id)}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Delete alert"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<Badge variant="secondary">Paused</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleAlert(alert.id)}
|
||||
disabled={togglingId === alert.id}
|
||||
title="Resume alert"
|
||||
>
|
||||
{togglingId === alert.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteAlert(alert.id)}
|
||||
disabled={deletingId === alert.id}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Delete alert"
|
||||
>
|
||||
{deletingId === alert.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import {
|
||||
mockFavorites,
|
||||
mockAlerts,
|
||||
mockSavedSearches,
|
||||
mockProducts,
|
||||
} from '../../mockData';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { getFavorites, getAlerts, getAlertStats, getSavedSearches } from '../../api/consumer';
|
||||
import {
|
||||
Heart,
|
||||
Bell,
|
||||
@@ -17,23 +13,80 @@ import {
|
||||
ChevronRight,
|
||||
TrendingDown,
|
||||
Search,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Dashboard = () => {
|
||||
// Get favorite products
|
||||
const favoriteProducts = mockProducts.filter((p) =>
|
||||
mockFavorites.includes(p.id)
|
||||
);
|
||||
const { isAuthenticated, user, authFetch, requireAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Get active alerts
|
||||
const activeAlerts = mockAlerts.filter((a) => a.active);
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [alerts, setAlerts] = useState([]);
|
||||
const [alertStats, setAlertStats] = useState({ active: 0, triggeredThisWeek: 0 });
|
||||
const [savedSearches, setSavedSearches] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Redirect to home if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
requireAuth(() => navigate('/dashboard'));
|
||||
}
|
||||
}, [isAuthenticated, requireAuth, navigate]);
|
||||
|
||||
// Fetch all dashboard data
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [favData, alertData, statsData, searchData] = await Promise.all([
|
||||
getFavorites(authFetch).catch(() => ({ favorites: [] })),
|
||||
getAlerts(authFetch).catch(() => ({ alerts: [] })),
|
||||
getAlertStats(authFetch).catch(() => ({ active: 0, triggeredThisWeek: 0 })),
|
||||
getSavedSearches(authFetch).catch(() => ({ savedSearches: [] })),
|
||||
]);
|
||||
|
||||
setFavorites(favData.favorites || []);
|
||||
setAlerts(alertData.alerts || []);
|
||||
setAlertStats(statsData);
|
||||
setSavedSearches(searchData.savedSearches || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [isAuthenticated, authFetch]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null; // Will redirect via useEffect
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeAlerts = alerts.filter(a => a.isActive);
|
||||
const triggeredAlerts = alerts.filter(a => a.isTriggered);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome back, {user?.firstName || 'there'}!
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Manage your favorites, alerts, and saved searches
|
||||
</p>
|
||||
@@ -41,6 +94,12 @@ const Dashboard = () => {
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
@@ -48,7 +107,7 @@ const Dashboard = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Favorites</p>
|
||||
<p className="text-2xl font-bold">{mockFavorites.length}</p>
|
||||
<p className="text-2xl font-bold">{favorites.length}</p>
|
||||
</div>
|
||||
<Heart className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
@@ -60,7 +119,7 @@ const Dashboard = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Active Alerts</p>
|
||||
<p className="text-2xl font-bold">{activeAlerts.length}</p>
|
||||
<p className="text-2xl font-bold">{alertStats.active}</p>
|
||||
</div>
|
||||
<Bell className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
@@ -72,7 +131,7 @@ const Dashboard = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Saved Searches</p>
|
||||
<p className="text-2xl font-bold">{mockSavedSearches.length}</p>
|
||||
<p className="text-2xl font-bold">{savedSearches.length}</p>
|
||||
</div>
|
||||
<Bookmark className="h-8 w-8 text-indigo-500" />
|
||||
</div>
|
||||
@@ -84,7 +143,7 @@ const Dashboard = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Price Drops</p>
|
||||
<p className="text-2xl font-bold">3</p>
|
||||
<p className="text-2xl font-bold">{triggeredAlerts.length}</p>
|
||||
</div>
|
||||
<TrendingDown className="h-8 w-8 text-green-500" />
|
||||
</div>
|
||||
@@ -108,28 +167,39 @@ const Dashboard = () => {
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{favoriteProducts.length > 0 ? (
|
||||
{favorites.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{favoriteProducts.slice(0, 3).map((product) => (
|
||||
{favorites.slice(0, 3).map((fav) => (
|
||||
<Link
|
||||
key={product.id}
|
||||
to={`/products/${product.id}`}
|
||||
key={fav.id}
|
||||
to={`/products/${fav.productId}`}
|
||||
className="flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={product.image || '/placeholder-product.jpg'}
|
||||
alt={product.name}
|
||||
src={fav.imageUrl || '/placeholder-product.jpg'}
|
||||
alt={fav.savedName}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{product.name}
|
||||
{fav.currentName || fav.savedName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{product.brand}</p>
|
||||
<p className="text-sm text-gray-500">{fav.currentBrand || fav.savedBrand}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
{fav.currentPrice ? (
|
||||
<p className="font-bold text-primary">
|
||||
${parseFloat(fav.currentPrice).toFixed(2)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No price</p>
|
||||
)}
|
||||
{fav.priceDrop && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
Price dropped!
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-bold text-primary">
|
||||
${product.price.toFixed(2)}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -160,34 +230,40 @@ const Dashboard = () => {
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mockAlerts.length > 0 ? (
|
||||
{alerts.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{mockAlerts.slice(0, 3).map((alert) => {
|
||||
const product = mockProducts.find((p) => p.id === alert.productId);
|
||||
return (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-center gap-4 p-3 rounded-lg bg-gray-50"
|
||||
>
|
||||
<img
|
||||
src={product?.image || '/placeholder-product.jpg'}
|
||||
alt={product?.name}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{product?.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Alert at ${alert.targetPrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={alert.active ? 'default' : 'secondary'}>
|
||||
{alert.active ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className={`flex items-center gap-4 p-3 rounded-lg ${
|
||||
alert.isTriggered ? 'bg-green-50' : 'bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={alert.productImage || '/placeholder-product.jpg'}
|
||||
alt={alert.productName}
|
||||
className="w-12 h-12 rounded-lg object-cover"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">
|
||||
{alert.productName}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Target: ${parseFloat(alert.targetPrice).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{alert.isTriggered ? (
|
||||
<Badge variant="success" className="flex items-center gap-1">
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
Triggered!
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={alert.isActive ? 'default' : 'secondary'}>
|
||||
{alert.isActive ? 'Active' : 'Paused'}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
@@ -216,22 +292,41 @@ const Dashboard = () => {
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{mockSavedSearches.length > 0 ? (
|
||||
{savedSearches.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{mockSavedSearches.slice(0, 4).map((search) => (
|
||||
<Link
|
||||
key={search.id}
|
||||
to={`/products?${new URLSearchParams(search.filters).toString()}`}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{search.name}</p>
|
||||
<p className="text-sm text-gray-500">{search.resultCount} results</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
))}
|
||||
{savedSearches.slice(0, 4).map((search) => {
|
||||
// Build search URL
|
||||
const params = new URLSearchParams();
|
||||
if (search.query) params.set('search', search.query);
|
||||
if (search.category) params.set('type', search.category);
|
||||
if (search.brand) params.set('brandName', search.brand);
|
||||
const searchUrl = `/products?${params.toString()}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={search.id}
|
||||
to={searchUrl}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900">{search.name}</p>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{search.category && (
|
||||
<Badge variant="secondary" className="text-xs">{search.category}</Badge>
|
||||
)}
|
||||
{search.brand && (
|
||||
<Badge variant="outline" className="text-xs">{search.brand}</Badge>
|
||||
)}
|
||||
{search.strainType && (
|
||||
<Badge variant="outline" className="text-xs">{search.strainType}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
|
||||
@@ -3,35 +3,63 @@ import { Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import { getDeals, getProducts, mapProductForUI } from '../../api/client';
|
||||
import { Tag, TrendingDown, Clock, Flame, Loader2 } from 'lucide-react';
|
||||
import { getSpecials, getDispensaries, mapProductForUI, mapDispensaryForUI } from '../../api/client';
|
||||
import { useGeolocation, sortByDistance } from '../../hooks/useGeolocation';
|
||||
import { Tag, TrendingDown, Clock, Flame, Loader2, MapPin, Navigation } from 'lucide-react';
|
||||
|
||||
const Deals = () => {
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [filter, setFilter] = useState('all');
|
||||
|
||||
// API state
|
||||
const [allProducts, setAllProducts] = useState([]);
|
||||
const [dealsProducts, setDealsProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// Geolocation
|
||||
const { location, loading: locationLoading, requestLocation } = useGeolocation({ autoRequest: true });
|
||||
|
||||
// Fetch data on mount
|
||||
// API state
|
||||
const [specials, setSpecials] = useState([]);
|
||||
const [nearbyDispensaryIds, setNearbyDispensaryIds] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Fetch nearby dispensaries when location is available
|
||||
useEffect(() => {
|
||||
const fetchNearbyDispensaries = async () => {
|
||||
if (!location) return;
|
||||
|
||||
try {
|
||||
const res = await getDispensaries({ limit: 100, hasProducts: true });
|
||||
const dispensaries = (res.dispensaries || []).map(mapDispensaryForUI);
|
||||
|
||||
// Sort by distance and get IDs of nearest 20
|
||||
const sorted = sortByDistance(dispensaries, location, (d) => ({
|
||||
latitude: d.latitude,
|
||||
longitude: d.longitude
|
||||
}));
|
||||
|
||||
const nearbyIds = sorted.slice(0, 20).map(d => d.id);
|
||||
setNearbyDispensaryIds(nearbyIds);
|
||||
} catch (err) {
|
||||
console.error('Error fetching nearby dispensaries:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNearbyDispensaries();
|
||||
}, [location]);
|
||||
|
||||
// Fetch specials on mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [dealsRes, productsRes] = await Promise.all([
|
||||
getDeals({ limit: 50 }),
|
||||
getProducts({ limit: 50 }),
|
||||
]);
|
||||
const res = await getSpecials({ limit: 100 });
|
||||
|
||||
// Set deals products (products with sale_price)
|
||||
setDealsProducts((dealsRes.products || []).map(mapProductForUI));
|
||||
|
||||
// Set all products for fallback display
|
||||
setAllProducts((productsRes.products || []).map(mapProductForUI));
|
||||
const products = (res.products || []).map(mapProductForUI);
|
||||
setSpecials(products);
|
||||
setTotalCount(res.pagination?.total || products.length);
|
||||
setHasMore(res.pagination?.has_more || false);
|
||||
} catch (err) {
|
||||
console.error('Error fetching deals data:', err);
|
||||
console.error('Error fetching specials:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -39,6 +67,22 @@ const Deals = () => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const res = await getSpecials({ limit: 50, offset: specials.length });
|
||||
const newProducts = (res.products || []).map(mapProductForUI);
|
||||
setSpecials(prev => [...prev, ...newProducts]);
|
||||
setHasMore(res.pagination?.has_more || false);
|
||||
} catch (err) {
|
||||
console.error('Error loading more specials:', err);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites((prev) =>
|
||||
prev.includes(productId)
|
||||
@@ -47,31 +91,39 @@ const Deals = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Use dealsProducts if available, otherwise fall back to allProducts
|
||||
const displayProducts = dealsProducts.length > 0 ? dealsProducts : allProducts;
|
||||
// Filter specials to only show products from nearby dispensaries
|
||||
const nearbySpecials = nearbyDispensaryIds.length > 0
|
||||
? specials.filter(p => nearbyDispensaryIds.includes(p.dispensaryId))
|
||||
: specials;
|
||||
|
||||
// Create some "deal categories" from available products
|
||||
const hotDeals = displayProducts.slice(0, 4);
|
||||
const todayOnly = displayProducts.slice(4, 8);
|
||||
const weeklySpecials = displayProducts.slice(0, 8);
|
||||
// Filter products by category for display sections
|
||||
const flowerSpecials = nearbySpecials.filter(p =>
|
||||
p.category?.toLowerCase().includes('flower')
|
||||
).slice(0, 8);
|
||||
const edibleSpecials = nearbySpecials.filter(p =>
|
||||
p.category?.toLowerCase().includes('edible')
|
||||
).slice(0, 8);
|
||||
const concentrateSpecials = nearbySpecials.filter(p =>
|
||||
p.category?.toLowerCase().includes('concentrate') || p.category?.toLowerCase().includes('vape')
|
||||
).slice(0, 8);
|
||||
|
||||
const filterOptions = [
|
||||
{ id: 'all', label: 'All Deals', icon: Tag },
|
||||
{ id: 'hot', label: 'Hot Deals', icon: Flame },
|
||||
{ id: 'today', label: 'Today Only', icon: Clock },
|
||||
{ id: 'weekly', label: 'Weekly Specials', icon: TrendingDown },
|
||||
{ id: 'all', label: 'All Specials', icon: Tag },
|
||||
{ id: 'flower', label: 'Flower', icon: Flame },
|
||||
{ id: 'edibles', label: 'Edibles', icon: Clock },
|
||||
{ id: 'concentrates', label: 'Concentrates', icon: TrendingDown },
|
||||
];
|
||||
|
||||
const getFilteredProducts = () => {
|
||||
switch (filter) {
|
||||
case 'hot':
|
||||
return hotDeals;
|
||||
case 'today':
|
||||
return todayOnly;
|
||||
case 'weekly':
|
||||
return weeklySpecials;
|
||||
case 'flower':
|
||||
return flowerSpecials;
|
||||
case 'edibles':
|
||||
return edibleSpecials;
|
||||
case 'concentrates':
|
||||
return concentrateSpecials;
|
||||
default:
|
||||
return displayProducts;
|
||||
return nearbySpecials;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,6 +141,18 @@ const Deals = () => {
|
||||
<p className="text-lg text-pink-100 max-w-2xl mx-auto">
|
||||
Save big on top cannabis products. Prices updated daily from dispensaries near you.
|
||||
</p>
|
||||
{location && (
|
||||
<div className="mt-4 inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span className="text-sm">Showing deals near your location</span>
|
||||
</div>
|
||||
)}
|
||||
{locationLoading && (
|
||||
<div className="mt-4 inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">Finding deals near you...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -98,17 +162,25 @@ const Deals = () => {
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">{loading ? '...' : dealsProducts.length > 0 ? `${dealsProducts.length}+` : `${allProducts.length}+`}</p>
|
||||
<p className="text-sm text-gray-600">Products on Sale</p>
|
||||
<p className="text-2xl font-bold text-pink-600">
|
||||
{loading ? '...' : nearbySpecials.length > 0 ? nearbySpecials.length : '0'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{location ? 'Specials Near You' : 'Products on Special'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">
|
||||
{nearbyDispensaryIds.length > 0 ? nearbyDispensaryIds.length : '20+'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{location ? 'Nearby Dispensaries' : 'Dispensaries'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">Up to 40%</p>
|
||||
<p className="text-sm text-gray-600">Savings</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">200+</p>
|
||||
<p className="text-sm text-gray-600">Dispensaries</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-pink-600">Daily</p>
|
||||
<p className="text-sm text-gray-600">Price Updates</p>
|
||||
@@ -153,74 +225,28 @@ const Deals = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hot Deals Section */}
|
||||
{(filter === 'all' || filter === 'hot') && (
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Flame className="h-6 w-6 text-orange-500" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Hot Deals</h2>
|
||||
<Badge variant="deal">Limited Time</Badge>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{(filter === 'hot' ? getFilteredProducts() : hotDeals).map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Today Only Section */}
|
||||
{(filter === 'all' || filter === 'today') && (
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Clock className="h-6 w-6 text-red-500" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Today Only</h2>
|
||||
<Badge className="bg-red-100 text-red-800">Ends at Midnight</Badge>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{(filter === 'today' ? getFilteredProducts() : todayOnly).map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={favorites.includes(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Weekly Specials Section */}
|
||||
{(filter === 'all' || filter === 'weekly') && (
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{/* Products Section */}
|
||||
<section className="mb-12">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="h-6 w-6 text-green-500" />
|
||||
<h2 className="text-2xl font-bold text-gray-900">Weekly Specials</h2>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{filter === 'all' ? 'All Specials' :
|
||||
filter === 'flower' ? 'Flower Specials' :
|
||||
filter === 'edibles' ? 'Edible Specials' : 'Concentrate Specials'}
|
||||
</h2>
|
||||
<Badge variant="deal">{getFilteredProducts().length} products</Badge>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : getFilteredProducts().length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{(filter === 'weekly' ? getFilteredProducts() : weeklySpecials).map((product) => (
|
||||
{getFilteredProducts().map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
@@ -229,9 +255,38 @@ const Deals = () => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Load More Button */}
|
||||
{filter === 'all' && hasMore && (
|
||||
<div className="text-center mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More Specials'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg">
|
||||
<Tag className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">No specials found in this category.</p>
|
||||
<Button variant="outline" onClick={() => setFilter('all')}>
|
||||
View All Specials
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-white rounded-xl p-8 text-center">
|
||||
|
||||
654
findagram/frontend/src/pages/findagram/DispensaryDetail.jsx
Normal file
654
findagram/frontend/src/pages/findagram/DispensaryDetail.jsx
Normal file
@@ -0,0 +1,654 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import {
|
||||
getDispensaryBySlug,
|
||||
getDispensarySummary,
|
||||
getDispensaryBrands,
|
||||
getDispensaryCategories,
|
||||
getStoreProducts,
|
||||
mapDispensaryForUI,
|
||||
mapProductForUI,
|
||||
} from '../../api/client';
|
||||
import {
|
||||
Store,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Phone,
|
||||
Globe,
|
||||
Clock,
|
||||
Truck,
|
||||
ShoppingBag,
|
||||
Car,
|
||||
Loader2,
|
||||
Package,
|
||||
Tag,
|
||||
Filter,
|
||||
X,
|
||||
Search,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
|
||||
const DispensaryDetail = () => {
|
||||
const { slug } = useParams();
|
||||
const [dispensary, setDispensary] = useState(null);
|
||||
const [summary, setSummary] = useState(null);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [productsLoading, setProductsLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Filters
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState('');
|
||||
const [selectedBrand, setSelectedBrand] = useState('');
|
||||
const [stockFilter, setStockFilter] = useState('in_stock');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Pagination
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const LIMIT = 24;
|
||||
|
||||
// Fetch dispensary data
|
||||
useEffect(() => {
|
||||
const fetchDispensaryData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// First get dispensary by slug
|
||||
const dispRes = await getDispensaryBySlug(slug);
|
||||
const mappedDispensary = mapDispensaryForUI(dispRes);
|
||||
setDispensary(mappedDispensary);
|
||||
|
||||
// Then fetch related data using the dispensary ID
|
||||
const dispensaryId = mappedDispensary.id;
|
||||
const [summaryRes, brandsRes, catsRes] = await Promise.all([
|
||||
getDispensarySummary(dispensaryId).catch(() => null),
|
||||
getDispensaryBrands(dispensaryId).catch(() => []),
|
||||
getDispensaryCategories(dispensaryId).catch(() => []),
|
||||
]);
|
||||
|
||||
setSummary(summaryRes);
|
||||
setBrands(brandsRes.brands || brandsRes || []);
|
||||
setCategories(catsRes.categories || catsRes || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching dispensary:', err);
|
||||
setError(err.message || 'Failed to load dispensary');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDispensaryData();
|
||||
}, [slug]);
|
||||
|
||||
// Fetch products when filters change
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
if (!dispensary) return;
|
||||
|
||||
try {
|
||||
setProductsLoading(true);
|
||||
const res = await getStoreProducts(dispensary.id, {
|
||||
search: searchTerm || undefined,
|
||||
type: selectedCategory || undefined,
|
||||
brandName: selectedBrand || undefined,
|
||||
stockStatus: stockFilter || undefined,
|
||||
limit: LIMIT,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
const mapped = (res.products || []).map(mapProductForUI);
|
||||
setProducts(mapped);
|
||||
setOffset(0);
|
||||
setHasMore(mapped.length === LIMIT);
|
||||
} catch (err) {
|
||||
console.error('Error fetching products:', err);
|
||||
} finally {
|
||||
setProductsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProducts();
|
||||
}, [dispensary, searchTerm, selectedCategory, selectedBrand, stockFilter]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (productsLoading || !hasMore || !dispensary) return;
|
||||
|
||||
try {
|
||||
setProductsLoading(true);
|
||||
const newOffset = offset + LIMIT;
|
||||
const res = await getStoreProducts(dispensary.id, {
|
||||
search: searchTerm || undefined,
|
||||
type: selectedCategory || undefined,
|
||||
brandName: selectedBrand || undefined,
|
||||
stockStatus: stockFilter || undefined,
|
||||
limit: LIMIT,
|
||||
offset: newOffset,
|
||||
});
|
||||
|
||||
const mapped = (res.products || []).map(mapProductForUI);
|
||||
setProducts((prev) => [...prev, ...mapped]);
|
||||
setOffset(newOffset);
|
||||
setHasMore(mapped.length === LIMIT);
|
||||
} catch (err) {
|
||||
console.error('Error loading more products:', err);
|
||||
} finally {
|
||||
setProductsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setSelectedCategory('');
|
||||
setSelectedBrand('');
|
||||
setStockFilter('in_stock');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchTerm || selectedCategory || selectedBrand || stockFilter !== 'in_stock';
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto mb-4" />
|
||||
<p className="text-gray-600">Loading dispensary...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !dispensary) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Dispensary Not Found</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{error || "The dispensary you're looking for doesn't exist."}
|
||||
</p>
|
||||
<Link to="/">
|
||||
<Button>Back to Home</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Breadcrumb */}
|
||||
<div className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<nav className="flex items-center space-x-2 text-sm">
|
||||
<Link to="/" className="text-gray-500 hover:text-primary">
|
||||
Home
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<Link to="/dispensaries" className="text-gray-500 hover:text-primary">
|
||||
Dispensaries
|
||||
</Link>
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-gray-900 truncate max-w-[200px]">{dispensary.name}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dispensary Header */}
|
||||
<section className="bg-white border-b">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Left: Main Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Dispensary Image/Icon */}
|
||||
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shrink-0">
|
||||
{dispensary.imageUrl ? (
|
||||
<img
|
||||
src={dispensary.imageUrl}
|
||||
alt={dispensary.name}
|
||||
className="w-full h-full object-cover rounded-xl"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Store className="h-10 w-10 text-white" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">
|
||||
{dispensary.name}
|
||||
</h1>
|
||||
|
||||
{/* Location */}
|
||||
<div className="flex items-center text-gray-600 mb-3">
|
||||
<MapPin className="h-4 w-4 mr-1 shrink-0" />
|
||||
<span className="truncate">
|
||||
{dispensary.address && `${dispensary.address}, `}
|
||||
{dispensary.city}, {dispensary.state} {dispensary.zip}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dispensary.licenseType?.recreational && (
|
||||
<Badge className="bg-green-100 text-green-800">Recreational</Badge>
|
||||
)}
|
||||
{dispensary.licenseType?.medical && (
|
||||
<Badge className="bg-blue-100 text-blue-800">Medical</Badge>
|
||||
)}
|
||||
{dispensary.services?.delivery && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Truck className="h-3 w-3" />
|
||||
Delivery
|
||||
</Badge>
|
||||
)}
|
||||
{dispensary.services?.pickup && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<ShoppingBag className="h-3 w-3" />
|
||||
Pickup
|
||||
</Badge>
|
||||
)}
|
||||
{dispensary.services?.curbside && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<Car className="h-3 w-3" />
|
||||
Curbside
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<Package className="h-6 w-6 text-primary mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{summary?.productCount || dispensary.productCount || 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Products</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<Tag className="h-6 w-6 text-primary mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{brands.length || dispensary.brandCount || 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Brands</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<Filter className="h-6 w-6 text-primary mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{categories.length || dispensary.categoryCount || 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Categories</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<ShoppingBag className="h-6 w-6 text-green-500 mx-auto mb-1" />
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{summary?.inStockCount || dispensary.inStockCount || 0}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">In Stock</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Contact Info */}
|
||||
<div className="lg:w-80">
|
||||
<Card>
|
||||
<CardContent className="p-6 space-y-4">
|
||||
<h3 className="font-semibold text-gray-900">Store Info</h3>
|
||||
|
||||
{dispensary.website && (
|
||||
<a
|
||||
href={dispensary.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-sm text-primary hover:underline"
|
||||
>
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
Visit Website
|
||||
</a>
|
||||
)}
|
||||
|
||||
{dispensary.menuUrl && (
|
||||
<a
|
||||
href={dispensary.menuUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center text-sm text-primary hover:underline"
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4 mr-2" />
|
||||
View Full Menu
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="flex items-start text-sm text-gray-600">
|
||||
<MapPin className="h-4 w-4 mr-2 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
{dispensary.address && <p>{dispensary.address}</p>}
|
||||
<p>
|
||||
{dispensary.city}, {dispensary.state} {dispensary.zip}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Map placeholder */}
|
||||
{dispensary.latitude && dispensary.longitude && (
|
||||
<a
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${dispensary.latitude},${dispensary.longitude}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<div className="h-32 bg-gray-100 rounded-lg flex items-center justify-center hover:bg-gray-200 transition-colors">
|
||||
<div className="text-center">
|
||||
<MapPin className="h-8 w-8 text-gray-400 mx-auto mb-1" />
|
||||
<span className="text-xs text-gray-500">View on Map</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Products Section */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Filters Sidebar - Desktop */}
|
||||
<div className="hidden lg:block lg:w-64 shrink-0">
|
||||
<div className="sticky top-4 space-y-6">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Filters</h3>
|
||||
|
||||
{/* Search */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
className="w-full pl-9 pr-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.type || cat} value={cat.type || cat}>
|
||||
{cat.type || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Brand */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Brand
|
||||
</label>
|
||||
<select
|
||||
value={selectedBrand}
|
||||
onChange={(e) => setSelectedBrand(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All Brands</option>
|
||||
{brands.map((brand) => (
|
||||
<option key={brand.brand_name || brand} value={brand.brand_name || brand}>
|
||||
{brand.brand_name || brand}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Stock Status */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Availability
|
||||
</label>
|
||||
<select
|
||||
value={stockFilter}
|
||||
onChange={(e) => setStockFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="in_stock">In Stock</option>
|
||||
<option value="">All Products</option>
|
||||
<option value="out_of_stock">Out of Stock</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="w-full"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1">
|
||||
{/* Mobile Filter Toggle */}
|
||||
<div className="lg:hidden mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="flex items-center">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
{hasActiveFilters && (
|
||||
<Badge className="ml-2 bg-primary text-white">Active</Badge>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
|
||||
{/* Mobile Filters */}
|
||||
{showFilters && (
|
||||
<Card className="mt-2">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
{/* Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search products..."
|
||||
className="w-full pl-9 pr-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.type || cat} value={cat.type || cat}>
|
||||
{cat.type || cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Brand */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Brand
|
||||
</label>
|
||||
<select
|
||||
value={selectedBrand}
|
||||
onChange={(e) => setSelectedBrand(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="">All</option>
|
||||
{brands.map((brand) => (
|
||||
<option key={brand.brand_name || brand} value={brand.brand_name || brand}>
|
||||
{brand.brand_name || brand}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stock Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Availability
|
||||
</label>
|
||||
<select
|
||||
value={stockFilter}
|
||||
onChange={(e) => setStockFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="in_stock">In Stock</option>
|
||||
<option value="">All Products</option>
|
||||
<option value="out_of_stock">Out of Stock</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
className="w-full"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
Products
|
||||
{products.length > 0 && (
|
||||
<span className="text-gray-500 font-normal ml-2">
|
||||
({products.length}{hasMore ? '+' : ''})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Products Grid */}
|
||||
{productsLoading && products.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : products.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
showDispensaryCount={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<div className="text-center mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={loadMore}
|
||||
disabled={productsLoading}
|
||||
>
|
||||
{productsLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load More Products'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-white rounded-lg">
|
||||
<Package className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500 mb-4">
|
||||
{hasActiveFilters
|
||||
? 'No products match your filters.'
|
||||
: 'No products available at this dispensary.'}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DispensaryDetail;
|
||||
@@ -1,26 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import ProductCard from '../../components/findagram/ProductCard';
|
||||
import { mockFavorites, mockProducts } from '../../mockData';
|
||||
import { Heart, Trash2 } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { getFavorites, removeFavorite } from '../../api/consumer';
|
||||
import { Heart, Trash2, Loader2 } from 'lucide-react';
|
||||
|
||||
const Favorites = () => {
|
||||
const [favorites, setFavorites] = useState(mockFavorites);
|
||||
const { isAuthenticated, authFetch, requireAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const favoriteProducts = mockProducts.filter((p) => favorites.includes(p.id));
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [removingId, setRemovingId] = useState(null);
|
||||
|
||||
const toggleFavorite = (productId) => {
|
||||
setFavorites((prev) =>
|
||||
prev.includes(productId)
|
||||
? prev.filter((id) => id !== productId)
|
||||
: [...prev, productId]
|
||||
// Redirect to home if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
requireAuth(() => navigate('/dashboard/favorites'));
|
||||
}
|
||||
}, [isAuthenticated, requireAuth, navigate]);
|
||||
|
||||
// Fetch favorites
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const fetchFavorites = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await getFavorites(authFetch);
|
||||
setFavorites(data.favorites || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFavorites();
|
||||
}, [isAuthenticated, authFetch]);
|
||||
|
||||
const handleRemoveFavorite = async (favoriteId) => {
|
||||
setRemovingId(favoriteId);
|
||||
try {
|
||||
await removeFavorite(authFetch, favoriteId);
|
||||
setFavorites(prev => prev.filter(f => f.id !== favoriteId));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setRemovingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllFavorites = async () => {
|
||||
if (!window.confirm('Are you sure you want to remove all favorites?')) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Remove all favorites one by one
|
||||
await Promise.all(favorites.map(f => removeFavorite(authFetch, f.id)));
|
||||
setFavorites([]);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const clearAllFavorites = () => {
|
||||
setFavorites([]);
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@@ -34,10 +93,10 @@ const Favorites = () => {
|
||||
My Favorites
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
{favoriteProducts.length} {favoriteProducts.length === 1 ? 'product' : 'products'} saved
|
||||
{favorites.length} {favorites.length === 1 ? 'product' : 'products'} saved
|
||||
</p>
|
||||
</div>
|
||||
{favoriteProducts.length > 0 && (
|
||||
{favorites.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={clearAllFavorites}
|
||||
@@ -52,15 +111,86 @@ const Favorites = () => {
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{favoriteProducts.length > 0 ? (
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{favorites.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{favoriteProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onFavorite={toggleFavorite}
|
||||
isFavorite={true}
|
||||
/>
|
||||
{favorites.map((fav) => (
|
||||
<div
|
||||
key={fav.id}
|
||||
className="bg-white rounded-lg shadow-sm border overflow-hidden group"
|
||||
>
|
||||
<Link to={`/products/${fav.productId}`}>
|
||||
<div className="relative aspect-square overflow-hidden bg-gray-100">
|
||||
<img
|
||||
src={fav.imageUrl || '/placeholder-product.jpg'}
|
||||
alt={fav.savedName}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
{fav.priceDrop && (
|
||||
<div className="absolute top-2 left-2 bg-green-500 text-white text-xs px-2 py-1 rounded">
|
||||
Price dropped!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
|
||||
{fav.currentBrand || fav.savedBrand}
|
||||
</p>
|
||||
<Link to={`/products/${fav.productId}`}>
|
||||
<h3 className="font-semibold text-gray-900 line-clamp-2 hover:text-primary transition-colors mb-2">
|
||||
{fav.currentName || fav.savedName}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
{fav.dispensaryName && (
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
at {fav.dispensaryName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
{fav.currentPrice ? (
|
||||
<p className="text-lg font-bold text-primary">
|
||||
${parseFloat(fav.currentPrice).toFixed(2)}
|
||||
</p>
|
||||
) : fav.savedPrice ? (
|
||||
<p className="text-lg font-bold text-gray-600">
|
||||
${parseFloat(fav.savedPrice).toFixed(2)}
|
||||
<span className="text-xs text-gray-400 ml-1">(saved)</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No price</p>
|
||||
)}
|
||||
{fav.priceDrop && fav.savedPrice && fav.currentPrice && (
|
||||
<p className="text-xs text-green-600">
|
||||
Was ${parseFloat(fav.savedPrice).toFixed(2)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveFavorite(fav.id)}
|
||||
disabled={removingId === fav.id}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
{removingId === fav.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Heart className="h-4 w-4 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -10,10 +10,14 @@ import {
|
||||
getDeals,
|
||||
getCategories,
|
||||
getBrands,
|
||||
getDispensaries,
|
||||
getStats,
|
||||
mapProductForUI,
|
||||
mapCategoryForUI,
|
||||
mapBrandForUI,
|
||||
mapDispensaryForUI,
|
||||
} from '../../api/client';
|
||||
import { useGeolocation, sortByDistance } from '../../hooks/useGeolocation';
|
||||
import {
|
||||
Search,
|
||||
Leaf,
|
||||
@@ -26,6 +30,9 @@ import {
|
||||
ShoppingBag,
|
||||
MapPin,
|
||||
Loader2,
|
||||
Navigation,
|
||||
Clock,
|
||||
Store,
|
||||
} from 'lucide-react';
|
||||
|
||||
const Home = () => {
|
||||
@@ -33,17 +40,22 @@ const Home = () => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [favorites, setFavorites] = useState([]);
|
||||
|
||||
// Geolocation - auto-request on mount
|
||||
const { location, loading: locationLoading, error: locationError, requestLocation, hasPermission } = useGeolocation({ autoRequest: true });
|
||||
|
||||
// API state
|
||||
const [featuredProducts, setFeaturedProducts] = useState([]);
|
||||
const [dealsProducts, setDealsProducts] = useState([]);
|
||||
const [categories, setCategories] = useState([]);
|
||||
const [brands, setBrands] = useState([]);
|
||||
const [nearbyDispensaries, setNearbyDispensaries] = useState([]);
|
||||
const [stats, setStats] = useState({
|
||||
products: 0,
|
||||
brands: 0,
|
||||
dispensaries: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dispensariesLoading, setDispensariesLoading] = useState(false);
|
||||
|
||||
// Fetch data on mount
|
||||
useEffect(() => {
|
||||
@@ -52,11 +64,12 @@ const Home = () => {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all data in parallel
|
||||
const [productsRes, dealsRes, categoriesRes, brandsRes] = await Promise.all([
|
||||
const [productsRes, dealsRes, categoriesRes, brandsRes, statsRes] = await Promise.all([
|
||||
getProducts({ limit: 4 }),
|
||||
getDeals({ limit: 4 }),
|
||||
getCategories(),
|
||||
getBrands({ limit: 100 }),
|
||||
getBrands({ limit: 500 }),
|
||||
getStats(),
|
||||
]);
|
||||
|
||||
// Set featured products
|
||||
@@ -75,15 +88,17 @@ const Home = () => {
|
||||
);
|
||||
|
||||
// Set brands (first 6 as popular)
|
||||
const allBrands = brandsRes.brands || [];
|
||||
setBrands(
|
||||
(brandsRes.brands || []).slice(0, 6).map(mapBrandForUI)
|
||||
allBrands.slice(0, 6).map(mapBrandForUI)
|
||||
);
|
||||
|
||||
// Set stats
|
||||
// Set stats from dedicated stats endpoint
|
||||
const statsData = statsRes.stats || {};
|
||||
setStats({
|
||||
products: productsRes.pagination?.total || 0,
|
||||
brands: brandsRes.pagination?.total || 0,
|
||||
dispensaries: 200, // Hardcoded for now - could add API endpoint
|
||||
products: statsData.products || 0,
|
||||
brands: statsData.brands || 0,
|
||||
dispensaries: statsData.dispensaries || 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error fetching home data:', err);
|
||||
@@ -95,6 +110,35 @@ const Home = () => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Fetch nearby dispensaries when location is available
|
||||
useEffect(() => {
|
||||
const fetchNearbyDispensaries = async () => {
|
||||
if (!location) return;
|
||||
|
||||
try {
|
||||
setDispensariesLoading(true);
|
||||
// Fetch dispensaries with products
|
||||
const res = await getDispensaries({ limit: 100, hasProducts: true });
|
||||
const dispensaries = (res.dispensaries || []).map(mapDispensaryForUI);
|
||||
|
||||
// Sort by distance from user
|
||||
const sorted = sortByDistance(dispensaries, location, (d) => ({
|
||||
latitude: d.latitude,
|
||||
longitude: d.longitude
|
||||
}));
|
||||
|
||||
// Take top 6 nearest
|
||||
setNearbyDispensaries(sorted.slice(0, 6));
|
||||
} catch (err) {
|
||||
console.error('Error fetching nearby dispensaries:', err);
|
||||
} finally {
|
||||
setDispensariesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchNearbyDispensaries();
|
||||
}, [location]);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
if (searchQuery.trim()) {
|
||||
@@ -203,8 +247,136 @@ const Home = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Products */}
|
||||
{/* Nearby Dispensaries Section */}
|
||||
<section className="py-12 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<MapPin className="h-6 w-6 text-primary" />
|
||||
{location ? 'Dispensaries Near You' : 'Find Dispensaries Near You'}
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{location
|
||||
? 'Sorted by distance from your location'
|
||||
: 'Enable location to see nearby stores'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!location && (
|
||||
<Button
|
||||
onClick={requestLocation}
|
||||
disabled={locationLoading}
|
||||
variant="outline"
|
||||
className="text-primary border-primary"
|
||||
>
|
||||
{locationLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Navigation className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Use My Location
|
||||
</Button>
|
||||
)}
|
||||
<Link to="/dispensaries">
|
||||
<Button variant="ghost" className="text-primary">
|
||||
View All
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dispensariesLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : nearbyDispensaries.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{nearbyDispensaries.map((dispensary) => (
|
||||
<Link
|
||||
key={dispensary.id}
|
||||
to={`/dispensaries/${dispensary.slug || dispensary.id}`}
|
||||
className="group"
|
||||
>
|
||||
<Card className="h-full transition-all hover:shadow-lg hover:-translate-y-1">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{dispensary.imageUrl ? (
|
||||
<img
|
||||
src={dispensary.imageUrl}
|
||||
alt={dispensary.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Store className="h-7 w-7 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 group-hover:text-primary transition-colors truncate">
|
||||
{dispensary.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{dispensary.city}, {dispensary.state}
|
||||
</p>
|
||||
{dispensary.distance !== null && (
|
||||
<p className="text-sm text-primary font-medium mt-1">
|
||||
{dispensary.distance} mi away
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
|
||||
{dispensary.productCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{dispensary.productCount} products
|
||||
</Badge>
|
||||
)}
|
||||
{dispensary.rating && (
|
||||
<Badge variant="outline" className="text-xs flex items-center gap-1">
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
{dispensary.rating}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : location ? (
|
||||
<div className="text-center py-8">
|
||||
<Store className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">No dispensaries found nearby</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-white rounded-lg border-2 border-dashed border-gray-200">
|
||||
<Navigation className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-600 font-medium mb-2">Find dispensaries near you</p>
|
||||
<p className="text-gray-500 text-sm mb-4 max-w-md mx-auto">
|
||||
We'll use your location to show the closest dispensaries, their products, and prices. Your location is never stored or shared.
|
||||
</p>
|
||||
<Button
|
||||
onClick={requestLocation}
|
||||
disabled={locationLoading}
|
||||
className="gradient-purple"
|
||||
>
|
||||
{locationLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<MapPin className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Share My Location
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Products */}
|
||||
<section className="py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
@@ -236,7 +408,7 @@ const Home = () => {
|
||||
</section>
|
||||
|
||||
{/* Deals Section */}
|
||||
<section className="py-12 bg-white">
|
||||
<section className="py-12 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
@@ -280,7 +452,7 @@ const Home = () => {
|
||||
</section>
|
||||
|
||||
{/* Browse by Category */}
|
||||
<section className="py-12 bg-gray-50">
|
||||
<section className="py-12 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Browse by Category</h2>
|
||||
@@ -320,7 +492,7 @@ const Home = () => {
|
||||
</section>
|
||||
|
||||
{/* Popular Brands */}
|
||||
<section className="py-12 bg-white">
|
||||
<section className="py-12 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
|
||||
@@ -1,23 +1,84 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Card, CardContent } from '../../components/ui/card';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { mockSavedSearches } from '../../mockData';
|
||||
import { Bookmark, Search, Trash2, ChevronRight } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { getSavedSearches, deleteSavedSearch } from '../../api/consumer';
|
||||
import { Bookmark, Search, Trash2, ChevronRight, Loader2 } from 'lucide-react';
|
||||
|
||||
const SavedSearches = () => {
|
||||
const [searches, setSearches] = useState(mockSavedSearches);
|
||||
const { isAuthenticated, authFetch, requireAuth } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const deleteSearch = (searchId) => {
|
||||
setSearches((prev) => prev.filter((search) => search.id !== searchId));
|
||||
const [searches, setSearches] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
|
||||
// Redirect to home if not authenticated
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
requireAuth(() => navigate('/dashboard/searches'));
|
||||
}
|
||||
}, [isAuthenticated, requireAuth, navigate]);
|
||||
|
||||
// Fetch saved searches
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const fetchSearches = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await getSavedSearches(authFetch);
|
||||
setSearches(data.savedSearches || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSearches();
|
||||
}, [isAuthenticated, authFetch]);
|
||||
|
||||
const handleDeleteSearch = async (searchId) => {
|
||||
setDeletingId(searchId);
|
||||
try {
|
||||
await deleteSavedSearch(authFetch, searchId);
|
||||
setSearches(prev => prev.filter(s => s.id !== searchId));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const buildSearchUrl = (filters) => {
|
||||
const params = new URLSearchParams(filters);
|
||||
const buildSearchUrl = (search) => {
|
||||
const params = new URLSearchParams();
|
||||
if (search.query) params.set('search', search.query);
|
||||
if (search.category) params.set('type', search.category);
|
||||
if (search.brand) params.set('brandName', search.brand);
|
||||
if (search.strainType) params.set('strainType', search.strainType);
|
||||
if (search.minPrice) params.set('minPrice', search.minPrice);
|
||||
if (search.maxPrice) params.set('maxPrice', search.maxPrice);
|
||||
return `/products?${params.toString()}`;
|
||||
};
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
@@ -44,6 +105,12 @@ const SavedSearches = () => {
|
||||
</section>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searches.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{searches.map((search) => (
|
||||
@@ -56,31 +123,33 @@ const SavedSearches = () => {
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-900">{search.name}</h3>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{search.filters.category && (
|
||||
<Badge variant="secondary">{search.filters.category}</Badge>
|
||||
{search.query && (
|
||||
<Badge variant="secondary">"{search.query}"</Badge>
|
||||
)}
|
||||
{search.filters.strainType && (
|
||||
<Badge variant="outline">{search.filters.strainType}</Badge>
|
||||
{search.category && (
|
||||
<Badge variant="secondary">{search.category}</Badge>
|
||||
)}
|
||||
{search.filters.priceMax && (
|
||||
<Badge variant="outline">Under ${search.filters.priceMax}</Badge>
|
||||
{search.brand && (
|
||||
<Badge variant="outline">{search.brand}</Badge>
|
||||
)}
|
||||
{search.filters.thcMin && (
|
||||
<Badge variant="outline">THC {search.filters.thcMin}%+</Badge>
|
||||
{search.strainType && (
|
||||
<Badge variant="outline">{search.strainType}</Badge>
|
||||
)}
|
||||
{search.filters.search && (
|
||||
<Badge variant="secondary">"{search.filters.search}"</Badge>
|
||||
{search.maxPrice && (
|
||||
<Badge variant="outline">Under ${search.maxPrice}</Badge>
|
||||
)}
|
||||
{search.minThc && (
|
||||
<Badge variant="outline">THC {search.minThc}%+</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">{search.resultCount} results</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
<p className="text-xs text-gray-400">
|
||||
Saved {new Date(search.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={buildSearchUrl(search.filters)}>
|
||||
<Link to={buildSearchUrl(search)}>
|
||||
<Button variant="outline" size="sm">
|
||||
Run Search
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
@@ -89,11 +158,16 @@ const SavedSearches = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteSearch(search.id)}
|
||||
onClick={() => handleDeleteSearch(search.id)}
|
||||
disabled={deletingId === search.id}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
title="Delete search"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deletingId === search.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user