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:
Kelly
2025-12-10 00:44:59 -07:00
parent 0295637ed6
commit 56cc171287
61 changed files with 8591 additions and 2076 deletions

View File

@@ -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>
);
}

View File

@@ -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;

View 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;

View 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;

View File

@@ -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

View File

@@ -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 && (

View 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;

View 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;

View 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;

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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">

View 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;

View File

@@ -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>
) : (

View File

@@ -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>

View File

@@ -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>