feat: Add Findagram and FindADispo consumer frontends

- Add findagram.co React frontend with product search, brands, categories
- Add findadispo.com React frontend with dispensary locator
- Wire findagram to backend /api/az/* endpoints
- Update category/brand links to route to /products with filters
- Add k8s manifests for both frontends
- Add multi-domain user support migrations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-05 16:10:15 -07:00
parent d120a07ed7
commit a0f8d3911c
179 changed files with 140234 additions and 600 deletions

View File

@@ -0,0 +1,14 @@
# Findadispo Frontend Environment Variables
# Copy this file to .env.development or .env.production
# API URL for dispensary data endpoints (public API)
# Local development: http://localhost:3010
# Production: https://dispos.crawlsy.com (or your production API URL)
REACT_APP_DATA_API_URL=http://localhost:3010
# API Key for accessing the /api/v1/* endpoints
# Get this from the backend admin panel or database
REACT_APP_DATA_API_KEY=your_api_key_here
# Backend URL (for other backend services if needed)
REACT_APP_BACKEND_URL=http://localhost:8001

View File

@@ -0,0 +1,52 @@
# Build stage
FROM node:20-slim AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (using npm install since package-lock.json may not exist)
RUN npm install
# Copy source files
COPY . .
# Set build-time environment variable for API URL (CRA uses REACT_APP_ prefix)
ENV REACT_APP_API_URL=https://api.findadispo.com
# Build the app (CRA produces /build, not /dist)
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built assets from builder stage (CRA outputs to /build)
COPY --from=builder /app/build /usr/share/nginx/html
# Copy nginx config for SPA routing
RUN echo 'server { \
listen 80; \
server_name _; \
root /usr/share/nginx/html; \
index index.html; \
\
# Gzip compression \
gzip on; \
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; \
\
# Cache static assets \
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
\
# SPA fallback - serve index.html for all routes \
location / { \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,62 @@
{
"name": "findadispo-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@radix-ui/react-accordion": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-tooltip": "^1.0.7",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"react-scripts": "5.0.1",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#10B981" />
<meta name="description" content="Find licensed cannabis dispensaries near you. Search by location, compare ratings, and discover the best dispensaries in your area." />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Find a Dispensary - Cannabis Dispensary Locator</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{
"short_name": "Find a Dispo",
"name": "Find a Dispensary - Cannabis Locator",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#10B981",
"background_color": "#ffffff",
"orientation": "portrait-primary",
"categories": ["lifestyle", "shopping"]
}

View File

@@ -0,0 +1,113 @@
// Find a Dispensary PWA Service Worker
const CACHE_NAME = 'findadispo-v1';
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Service Worker: Caching static assets');
return cache.addAll(STATIC_ASSETS);
})
);
// Activate immediately
self.skipWaiting();
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log('Service Worker: Clearing old cache', name);
return caches.delete(name);
})
);
})
);
// Take control immediately
self.clients.claim();
});
// Fetch event - network first, fallback to cache
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return;
// Skip API requests
if (event.request.url.includes('/api/')) return;
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone response for caching
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
return response;
})
.catch(() => {
// Return cached version if offline
return caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Return offline page for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/index.html');
}
});
})
);
});
// Background sync for saved searches
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-searches') {
console.log('Service Worker: Syncing saved searches');
}
});
// Push notifications
self.addEventListener('push', (event) => {
const options = {
body: event.data?.text() || 'New update from Find a Dispensary',
icon: '/logo192.png',
badge: '/logo192.png',
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [
{ action: 'explore', title: 'View Details' },
{ action: 'close', title: 'Close' }
]
};
event.waitUntil(
self.registration.showNotification('Find a Dispensary', options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'explore') {
event.waitUntil(
clients.openWindow('/dashboard/alerts')
);
}
});

View File

@@ -0,0 +1,130 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
// Layout Components
import { Header } from './components/findadispo/Header';
import { Footer } from './components/findadispo/Footer';
// Page Components
import { Home } from './pages/findadispo/Home';
import { StoreLocator } from './pages/findadispo/StoreLocator';
import { DispensaryDetail } from './pages/findadispo/DispensaryDetail';
import { About } from './pages/findadispo/About';
import { Contact } from './pages/findadispo/Contact';
import { Login } from './pages/findadispo/Login';
import { Signup } from './pages/findadispo/Signup';
// Dashboard Components
import { Dashboard } from './pages/findadispo/Dashboard';
import { DashboardHome } from './pages/findadispo/DashboardHome';
import { SavedSearches } from './pages/findadispo/SavedSearches';
import { Alerts } from './pages/findadispo/Alerts';
import { Profile } from './pages/findadispo/Profile';
// Protected Route Component
function ProtectedRoute({ children }) {
const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
// Main Layout with Header and Footer
function MainLayout({ children }) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
// Dashboard Layout (no footer, custom header handled in Dashboard component)
function DashboardLayout({ children }) {
return (
<div className="min-h-screen flex flex-col">
<Header />
{children}
</div>
);
}
function App() {
return (
<Router>
<Routes>
{/* Public Routes with Main Layout */}
<Route
path="/"
element={
<MainLayout>
<Home />
</MainLayout>
}
/>
<Route
path="/store-locator"
element={
<MainLayout>
<StoreLocator />
</MainLayout>
}
/>
<Route
path="/dispensary/:slug"
element={
<MainLayout>
<DispensaryDetail />
</MainLayout>
}
/>
<Route
path="/about"
element={
<MainLayout>
<About />
</MainLayout>
}
/>
<Route
path="/contact"
element={
<MainLayout>
<Contact />
</MainLayout>
}
/>
{/* Auth Routes (no header/footer) */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
{/* Dashboard Routes (protected) */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardLayout>
<Dashboard />
</DashboardLayout>
</ProtectedRoute>
}
>
<Route index element={<DashboardHome />} />
<Route path="saved" element={<SavedSearches />} />
<Route path="alerts" element={<Alerts />} />
<Route path="profile" element={<Profile />} />
</Route>
{/* Catch-all redirect */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,290 @@
// Findadispo API Client
// Connects to /api/v1/* endpoints with X-API-Key authentication
import { API_CONFIG } from '../lib/utils';
const API_BASE_URL = API_CONFIG.DATA_API_URL;
const API_KEY = API_CONFIG.DATA_API_KEY;
// Helper function to make authenticated API requests
async function apiRequest(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
...(API_KEY && { 'X-API-Key': API_KEY }),
...options.headers,
};
const response = await fetch(url, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Request failed' }));
throw new Error(error.message || `API request failed: ${response.status}`);
}
return response.json();
}
/**
* Fetch dispensaries with optional filters
* @param {Object} params - Query parameters
* @param {string} params.search - Search query (name, city, zip)
* @param {string} params.state - State filter
* @param {string} params.city - City filter
* @param {number} params.limit - Results per page
* @param {number} params.offset - Pagination offset
* @returns {Promise<{dispensaries: Array, total: number, limit: number, offset: number}>}
*/
export async function getDispensaries(params = {}) {
const queryParams = new URLSearchParams();
if (params.search) queryParams.append('search', params.search);
if (params.state) queryParams.append('state', params.state);
if (params.city) queryParams.append('city', params.city);
if (params.limit) queryParams.append('limit', params.limit);
if (params.offset) queryParams.append('offset', params.offset);
const queryString = queryParams.toString();
const endpoint = `/api/v1/dispensaries${queryString ? `?${queryString}` : ''}`;
return apiRequest(endpoint);
}
/**
* Fetch a single dispensary by slug or ID
* @param {string} slugOrId - Dispensary slug or ID
* @returns {Promise<Object>}
*/
export async function getDispensaryBySlug(slugOrId) {
return apiRequest(`/api/v1/dispensaries/${slugOrId}`);
}
/**
* Fetch dispensary by ID
* @param {number} id - Dispensary ID
* @returns {Promise<Object>}
*/
export async function getDispensaryById(id) {
return apiRequest(`/api/v1/dispensaries/${id}`);
}
/**
* Map API dispensary response to UI format
* Converts snake_case API fields to camelCase UI fields
* and adds any default values for missing data
* @param {Object} apiDispensary - Dispensary from API
* @returns {Object} - Dispensary formatted for UI
*/
export function mapDispensaryForUI(apiDispensary) {
// Build full address from components
const addressParts = [
apiDispensary.address,
apiDispensary.city,
apiDispensary.state,
apiDispensary.zip
].filter(Boolean);
const fullAddress = addressParts.join(', ');
// Format hours for display
let hoursDisplay = 'Hours not available';
if (apiDispensary.hours) {
if (typeof apiDispensary.hours === 'string') {
hoursDisplay = apiDispensary.hours;
} else if (apiDispensary.hours.formatted) {
hoursDisplay = apiDispensary.hours.formatted;
} else {
// Try to format from day-by-day structure
hoursDisplay = formatHoursFromObject(apiDispensary.hours);
}
}
// Check if currently open based on hours
const isOpen = checkIfOpen(apiDispensary.hours);
// Handle location from nested object or flat fields
const lat = apiDispensary.location?.latitude || apiDispensary.latitude;
const lng = apiDispensary.location?.longitude || apiDispensary.longitude;
return {
id: apiDispensary.id,
name: apiDispensary.dba_name || apiDispensary.name,
slug: apiDispensary.slug || generateSlug(apiDispensary.name),
address: fullAddress,
city: apiDispensary.city,
state: apiDispensary.state,
zip: apiDispensary.zip,
phone: apiDispensary.phone || null,
hours: hoursDisplay,
hoursData: apiDispensary.hours || null, // Keep raw hours data for open/close logic
rating: apiDispensary.rating || 0,
reviews: apiDispensary.review_count || 0,
distance: apiDispensary.distance || null,
lat: lat,
lng: lng,
image: apiDispensary.image_url || 'https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=400&h=300&fit=crop',
isOpen: isOpen,
amenities: apiDispensary.amenities || [],
description: apiDispensary.description || 'Cannabis dispensary',
website: apiDispensary.website,
menuUrl: apiDispensary.menu_url,
menuType: apiDispensary.menu_type || apiDispensary.platform,
productCount: apiDispensary.product_count || 0,
inStockCount: apiDispensary.in_stock_count || 0,
lastUpdated: apiDispensary.last_updated,
dataAvailable: apiDispensary.data_available ?? true,
};
}
/**
* Generate a URL-friendly slug from dispensary name
*/
function generateSlug(name) {
if (!name) return '';
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Format hours from day-by-day object to readable string
*/
function formatHoursFromObject(hours) {
if (!hours || typeof hours !== 'object') return 'Hours not available';
// Try to create a simple string like "Mon-Sat 9am-9pm"
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const shortDays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
let result = [];
for (let i = 0; i < days.length; i++) {
const dayData = hours[days[i]];
if (dayData && dayData.open && dayData.close) {
result.push(`${shortDays[i]}: ${formatTime(dayData.open)}-${formatTime(dayData.close)}`);
}
}
return result.length > 0 ? result.join(', ') : 'Hours not available';
}
/**
* Format 24hr time to 12hr format
*/
function formatTime(time) {
if (!time) return '';
const [hours, minutes] = time.split(':');
const hour = parseInt(hours, 10);
const suffix = hour >= 12 ? 'pm' : 'am';
const displayHour = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
return minutes === '00' ? `${displayHour}${suffix}` : `${displayHour}:${minutes}${suffix}`;
}
/**
* Check if dispensary is currently open based on hours data
*/
function checkIfOpen(hours) {
if (!hours || typeof hours !== 'object') return true; // Default to open if no data
const now = new Date();
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const today = dayNames[now.getDay()];
const todayHours = hours[today];
if (!todayHours || !todayHours.open || !todayHours.close) return true;
const currentTime = now.getHours() * 60 + now.getMinutes();
const [openHour, openMin] = todayHours.open.split(':').map(Number);
const [closeHour, closeMin] = todayHours.close.split(':').map(Number);
const openTime = openHour * 60 + (openMin || 0);
const closeTime = closeHour * 60 + (closeMin || 0);
return currentTime >= openTime && currentTime <= closeTime;
}
/**
* Search dispensaries by query and filters
* This function provides a mockData-compatible interface
* @param {string} query - Search query
* @param {Object} filters - Filters (openNow, minRating, maxDistance, amenities)
* @returns {Promise<Array>} - Array of dispensaries
*/
export async function searchDispensaries(query, filters = {}) {
try {
const params = {
search: query || undefined,
limit: 100,
};
const response = await getDispensaries(params);
let dispensaries = (response.dispensaries || []).map(mapDispensaryForUI);
// Apply client-side filters that aren't supported by API
if (filters.openNow) {
dispensaries = dispensaries.filter(d => d.isOpen);
}
if (filters.minRating) {
dispensaries = dispensaries.filter(d => d.rating >= filters.minRating);
}
if (filters.maxDistance && filters.maxDistance < 100) {
dispensaries = dispensaries.filter(d => !d.distance || d.distance <= filters.maxDistance);
}
if (filters.amenities && filters.amenities.length > 0) {
dispensaries = dispensaries.filter(d =>
filters.amenities.every(amenity => d.amenities.includes(amenity))
);
}
return dispensaries;
} catch (error) {
console.error('Error searching dispensaries:', error);
return [];
}
}
/**
* Get list of unique cities for filter dropdown
* @returns {Promise<Array<string>>}
*/
export async function getCities() {
try {
const response = await getDispensaries({ limit: 500 });
const cities = [...new Set(
(response.dispensaries || [])
.map(d => d.city)
.filter(Boolean)
.sort()
)];
return cities;
} catch (error) {
console.error('Error fetching cities:', error);
return [];
}
}
/**
* Get list of unique states for filter dropdown
* @returns {Promise<Array<string>>}
*/
export async function getStates() {
try {
const response = await getDispensaries({ limit: 500 });
const states = [...new Set(
(response.dispensaries || [])
.map(d => d.state)
.filter(Boolean)
.sort()
)];
return states;
} catch (error) {
console.error('Error fetching states:', error);
return [];
}
}

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { MapPin, Mail, Phone } from 'lucide-react';
export function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-gray-900 text-gray-300">
<div className="container px-4 py-12">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="space-y-4">
<Link to="/" className="flex items-center space-x-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<MapPin className="h-5 w-5 text-white" />
</div>
<span className="text-lg font-bold text-white">Find a Dispensary</span>
</Link>
<p className="text-sm text-gray-400">
Helping you discover licensed cannabis dispensaries near you.
Find trusted locations, compare options, and access quality products.
</p>
</div>
{/* Quick Links */}
<div>
<h3 className="text-white font-semibold mb-4">Quick Links</h3>
<ul className="space-y-2">
<li>
<Link to="/" className="text-sm hover:text-primary transition-colors">
Home
</Link>
</li>
<li>
<Link to="/store-locator" className="text-sm hover:text-primary transition-colors">
Store Locator
</Link>
</li>
<li>
<Link to="/about" className="text-sm hover:text-primary transition-colors">
About Us
</Link>
</li>
<li>
<Link to="/contact" className="text-sm hover:text-primary transition-colors">
Contact
</Link>
</li>
</ul>
</div>
{/* Account */}
<div>
<h3 className="text-white font-semibold mb-4">Account</h3>
<ul className="space-y-2">
<li>
<Link to="/login" className="text-sm hover:text-primary transition-colors">
Log In
</Link>
</li>
<li>
<Link to="/signup" className="text-sm hover:text-primary transition-colors">
Sign Up
</Link>
</li>
<li>
<Link to="/dashboard" className="text-sm hover:text-primary transition-colors">
Dashboard
</Link>
</li>
<li>
<Link to="/dashboard/alerts" className="text-sm hover:text-primary transition-colors">
Price Alerts
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="text-white font-semibold mb-4">Contact Us</h3>
<ul className="space-y-3">
<li className="flex items-center space-x-2 text-sm">
<Mail className="h-4 w-4 text-primary" />
<a href="mailto:support@findadispo.com" className="hover:text-primary transition-colors">
support@findadispo.com
</a>
</li>
<li className="flex items-center space-x-2 text-sm">
<Phone className="h-4 w-4 text-primary" />
<span>(555) 123-4567</span>
</li>
</ul>
</div>
</div>
{/* Bottom Bar */}
<div className="border-t border-gray-800 mt-8 pt-8">
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<p className="text-sm text-gray-500">
{currentYear} Find a Dispensary. All rights reserved.
</p>
<div className="flex space-x-6">
<Link to="/privacy" className="text-sm text-gray-500 hover:text-gray-300 transition-colors">
Privacy Policy
</Link>
<Link to="/terms" className="text-sm text-gray-500 hover:text-gray-300 transition-colors">
Terms of Service
</Link>
</div>
</div>
</div>
</div>
</footer>
);
}
export default Footer;

View File

@@ -0,0 +1,128 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { MapPin, Menu, X, User, LogIn } from 'lucide-react';
import { Button } from '../ui/button';
export function Header() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const location = useLocation();
// Mock auth state - replace with real auth context
const isAuthenticated = false;
const navLinks = [
{ href: '/', label: 'Home' },
{ href: '/store-locator', label: 'Store Locator' },
{ href: '/about', label: 'About' },
{ href: '/contact', label: 'Contact' },
];
const isActive = (path) => location.pathname === path;
return (
<header className="sticky top-0 z-50 w-full border-b bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/60">
<div className="container flex h-16 items-center justify-between px-4">
{/* Logo */}
<Link to="/" className="flex items-center space-x-2">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<MapPin className="h-5 w-5 text-white" />
</div>
<div className="flex flex-col">
<span className="text-lg font-bold text-gray-900">Find a Dispensary</span>
<span className="text-xs text-gray-500 -mt-1">Cannabis Locator</span>
</div>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6">
{navLinks.map((link) => (
<Link
key={link.href}
to={link.href}
className={`text-sm font-medium transition-colors hover:text-primary ${
isActive(link.href) ? 'text-primary' : 'text-gray-600'
}`}
>
{link.label}
</Link>
))}
</nav>
{/* Auth Buttons */}
<div className="hidden md:flex items-center space-x-3">
{isAuthenticated ? (
<Link to="/dashboard">
<Button variant="ghost" size="sm" className="flex items-center gap-2">
<User className="h-4 w-4" />
Dashboard
</Button>
</Link>
) : (
<>
<Link to="/login">
<Button variant="ghost" size="sm">Log in</Button>
</Link>
<Link to="/signup">
<Button size="sm" className="flex items-center gap-2">
<LogIn className="h-4 w-4" />
Sign up
</Button>
</Link>
</>
)}
</div>
{/* Mobile Menu Button */}
<button
className="md:hidden p-2"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-label="Toggle menu"
>
{mobileMenuOpen ? (
<X className="h-6 w-6 text-gray-600" />
) : (
<Menu className="h-6 w-6 text-gray-600" />
)}
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t bg-white">
<nav className="container px-4 py-4 space-y-3">
{navLinks.map((link) => (
<Link
key={link.href}
to={link.href}
className={`block py-2 text-sm font-medium transition-colors ${
isActive(link.href) ? 'text-primary' : 'text-gray-600'
}`}
onClick={() => setMobileMenuOpen(false)}
>
{link.label}
</Link>
))}
<div className="pt-3 border-t space-y-2">
{isAuthenticated ? (
<Link to="/dashboard" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">Dashboard</Button>
</Link>
) : (
<>
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>
<Button variant="outline" className="w-full">Log in</Button>
</Link>
<Link to="/signup" onClick={() => setMobileMenuOpen(false)}>
<Button className="w-full">Sign up</Button>
</Link>
</>
)}
</div>
</nav>
</div>
)}
</header>
);
}
export default Header;

View File

@@ -0,0 +1,35 @@
import * as React from "react";
import { cva } from "class-variance-authority";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-100 text-green-800",
warning:
"border-transparent bg-yellow-100 text-yellow-800",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "../../lib/utils";
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva } from "class-variance-authority";
import { cn } from "../../lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "../../lib/utils";
const Separator = React.forwardRef(
({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "../../lib/utils";
const Slider = React.forwardRef(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View File

@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Textarea.displayName = "Textarea";
export { Textarea };

View File

@@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 160 84% 39%;
--primary-foreground: 210 40% 98%;
--secondary: 217 91% 60%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 38 92% 50%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 160 84% 39%;
--radius: 0.5rem;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('SW registered:', registration);
})
.catch(error => {
console.log('SW registration failed:', error);
});
});
}

View File

@@ -0,0 +1,89 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
// Format distance for display
export function formatDistance(miles) {
if (miles < 0.1) {
return "< 0.1 mi";
}
return `${miles.toFixed(1)} mi`;
}
// Format phone number
export function formatPhone(phone) {
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
}
return phone;
}
// Check if dispensary is currently open based on hours string
export function isCurrentlyOpen(hoursString) {
// This is a simplified check - in production you'd parse actual hours
return true;
}
// Generate star rating display
export function getStarRating(rating) {
const fullStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
return { fullStars, hasHalfStar, emptyStars: 5 - fullStars - (hasHalfStar ? 1 : 0) };
}
// Debounce function for search
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// Format date for display
export function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
// Validate email format
export function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// Validate phone format
export function isValidPhone(phone) {
const phoneRegex = /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/;
return phoneRegex.test(phone);
}
// Get initials from name
export function getInitials(name) {
if (!name) return '';
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
// API configuration
export const API_CONFIG = {
BASE_URL: process.env.REACT_APP_BACKEND_URL || 'http://localhost:8001',
DATA_API_URL: process.env.REACT_APP_DATA_API_URL || 'http://localhost:3010',
DATA_API_KEY: process.env.REACT_APP_DATA_API_KEY || ''
};

View File

@@ -0,0 +1,234 @@
// Mock dispensary data for development
export const mockDispensaries = [
{
id: 1,
name: "Green Haven Dispensary",
slug: "green-haven",
address: "123 Main St, Phoenix, AZ 85001",
phone: "(602) 555-0123",
hours: "9:00 AM - 9:00 PM",
rating: 4.8,
reviews: 342,
distance: 1.2,
lat: 33.4484,
lng: -112.0740,
image: "https://images.unsplash.com/photo-1587854692152-cbe660dbde88?w=400&h=300&fit=crop",
isOpen: true,
amenities: ["Parking", "Wheelchair Access", "ATM"],
description: "Premium cannabis dispensary offering a wide selection of flower, edibles, and concentrates."
},
{
id: 2,
name: "Desert Bloom Cannabis",
slug: "desert-bloom",
address: "456 Oak Ave, Scottsdale, AZ 85251",
phone: "(480) 555-0456",
hours: "10:00 AM - 8:00 PM",
rating: 4.6,
reviews: 218,
distance: 2.5,
lat: 33.4942,
lng: -111.9261,
image: "https://images.unsplash.com/photo-1603909223429-69bb7c5a7e97?w=400&h=300&fit=crop",
isOpen: true,
amenities: ["Parking", "Online Ordering"],
description: "Your neighborhood dispensary with knowledgeable staff and quality products."
},
{
id: 3,
name: "Cactus Wellness",
slug: "cactus-wellness",
address: "789 Cactus Rd, Tempe, AZ 85281",
phone: "(480) 555-0789",
hours: "8:00 AM - 10:00 PM",
rating: 4.9,
reviews: 567,
distance: 3.1,
lat: 33.4255,
lng: -111.9400,
image: "https://images.unsplash.com/photo-1585063560070-e4e3f0b5e0e1?w=400&h=300&fit=crop",
isOpen: true,
amenities: ["Parking", "Wheelchair Access", "ATM", "Online Ordering"],
description: "Award-winning dispensary focused on wellness and patient education."
},
{
id: 4,
name: "Mountain High Dispensary",
slug: "mountain-high",
address: "321 Summit Blvd, Mesa, AZ 85201",
phone: "(480) 555-0321",
hours: "9:00 AM - 9:00 PM",
rating: 4.4,
reviews: 156,
distance: 4.2,
lat: 33.4152,
lng: -111.8315,
image: "https://images.unsplash.com/photo-1616690710400-a16d146927c5?w=400&h=300&fit=crop",
isOpen: false,
amenities: ["Parking", "ATM"],
description: "Locally owned dispensary with competitive prices and daily deals."
},
{
id: 5,
name: "Valley Verde",
slug: "valley-verde",
address: "555 Valley View Dr, Glendale, AZ 85301",
phone: "(623) 555-0555",
hours: "10:00 AM - 9:00 PM",
rating: 4.7,
reviews: 289,
distance: 5.8,
lat: 33.5387,
lng: -112.1860,
image: "https://images.unsplash.com/photo-1558642452-9d2a7deb7f62?w=400&h=300&fit=crop",
isOpen: true,
amenities: ["Parking", "Wheelchair Access", "Online Ordering"],
description: "Family-friendly atmosphere with a focus on medical cannabis."
},
{
id: 6,
name: "Sunrise Cannabis Co",
slug: "sunrise-cannabis",
address: "888 Sunrise Blvd, Chandler, AZ 85225",
phone: "(480) 555-0888",
hours: "7:00 AM - 11:00 PM",
rating: 4.5,
reviews: 423,
distance: 6.3,
lat: 33.3062,
lng: -111.8413,
image: "https://images.unsplash.com/photo-1571166585747-8b6e1a93d2a7?w=400&h=300&fit=crop",
isOpen: true,
amenities: ["Parking", "Drive-Through", "ATM", "Online Ordering"],
description: "Open early for your convenience with drive-through service available."
},
{
id: 7,
name: "Oasis Dispensary",
slug: "oasis-dispensary",
address: "222 Palm Lane, Gilbert, AZ 85234",
phone: "(480) 555-0222",
hours: "9:00 AM - 8:00 PM",
rating: 4.3,
reviews: 178,
distance: 7.1,
lat: 33.3528,
lng: -111.7890,
image: "https://images.unsplash.com/photo-1585320806297-9794b3e4eeae?w=400&h=300&fit=crop",
isOpen: true,
amenities: ["Parking", "Wheelchair Access"],
description: "Relaxing environment with a curated selection of premium products."
},
{
id: 8,
name: "Copper State Cannabis",
slug: "copper-state",
address: "444 Copper Ave, Tucson, AZ 85701",
phone: "(520) 555-0444",
hours: "10:00 AM - 7:00 PM",
rating: 4.6,
reviews: 312,
distance: 8.5,
lat: 32.2226,
lng: -110.9747,
image: "https://images.unsplash.com/photo-1601055903647-ddf1ee9701b7?w=400&h=300&fit=crop",
isOpen: false,
amenities: ["Parking", "ATM", "Online Ordering"],
description: "Tucson's premier cannabis destination with Arizona-grown products."
}
];
// Mock saved searches for dashboard
export const mockSavedSearches = [
{
id: 1,
query: "Phoenix dispensaries",
filters: { distance: 5, rating: 4 },
createdAt: "2024-01-15T10:30:00Z"
},
{
id: 2,
query: "Open now Scottsdale",
filters: { openNow: true },
createdAt: "2024-01-10T14:20:00Z"
},
{
id: 3,
query: "Dispensaries with parking",
filters: { amenities: ["Parking"] },
createdAt: "2024-01-05T09:15:00Z"
}
];
// Mock alerts for dashboard
export const mockAlerts = [
{
id: 1,
dispensaryName: "Green Haven Dispensary",
alertType: "price_drop",
notifyVia: ["email"],
active: true,
createdAt: "2024-01-12T11:00:00Z"
},
{
id: 2,
dispensaryName: "Desert Bloom Cannabis",
alertType: "new_location",
notifyVia: ["email", "sms"],
active: true,
createdAt: "2024-01-08T16:45:00Z"
},
{
id: 3,
dispensaryName: "Cactus Wellness",
alertType: "price_drop",
notifyVia: ["sms"],
active: false,
createdAt: "2024-01-01T08:30:00Z"
}
];
// Mock user data
export const mockUser = {
id: 1,
name: "John Doe",
email: "john@example.com",
phone: "(555) 123-4567",
notifications: {
email: true,
sms: false,
marketing: false
}
};
// Helper function to get dispensary by slug
export const getDispensaryBySlug = (slug) => {
return mockDispensaries.find(d => d.slug === slug);
};
// Helper function to search dispensaries
export const searchDispensaries = (query, filters = {}) => {
let results = [...mockDispensaries];
if (query) {
const searchTerm = query.toLowerCase();
results = results.filter(d =>
d.name.toLowerCase().includes(searchTerm) ||
d.address.toLowerCase().includes(searchTerm)
);
}
if (filters.openNow) {
results = results.filter(d => d.isOpen);
}
if (filters.minRating) {
results = results.filter(d => d.rating >= filters.minRating);
}
if (filters.maxDistance) {
results = results.filter(d => d.distance <= filters.maxDistance);
}
return results;
};

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { MapPin, Shield, Users, TrendingUp } from 'lucide-react';
import { Card, CardContent } from '../../components/ui/card';
export function About() {
const stats = [
{ label: 'Dispensaries Tracked', value: '10,000+' },
{ label: 'States Covered', value: '35' },
{ label: 'Monthly Users', value: '500K+' },
{ label: 'Data Accuracy', value: '99.9%' },
];
const features = [
{
icon: MapPin,
title: 'Accurate Location Data',
description: 'Real-time, verified dispensary information including hours, contact details, and precise locations.',
},
{
icon: Shield,
title: 'Licensed Only',
description: 'We only list state-licensed dispensaries, ensuring you access safe, legal cannabis products.',
},
{
icon: Users,
title: 'Community Driven',
description: 'User reviews and ratings help you find the best dispensaries in your area.',
},
{
icon: TrendingUp,
title: 'Price Tracking',
description: 'Save money with our price alerts and discover deals at dispensaries near you.',
},
];
return (
<div className="min-h-screen bg-white">
{/* Hero Section */}
<section className="bg-gradient-to-b from-primary/5 to-white py-20">
<div className="container mx-auto px-4 text-center">
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
About Find a Dispensary
</h1>
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
We're on a mission to make finding quality cannabis products simple, safe, and accessible.
Our platform connects consumers with licensed dispensaries across the country.
</p>
</div>
</section>
{/* Stats Section */}
<section className="py-16 bg-white">
<div className="container mx-auto px-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-8">
{stats.map((stat, index) => (
<div key={index} className="text-center">
<p className="text-4xl font-bold text-primary">{stat.value}</p>
<p className="text-gray-600 mt-2">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
{/* Mission Section */}
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<div className="max-w-3xl mx-auto text-center">
<h2 className="text-3xl font-bold text-gray-900 mb-6">Our Mission</h2>
<p className="text-lg text-gray-600 leading-relaxed">
As the cannabis industry continues to grow and evolve, we believe everyone deserves
access to accurate, up-to-date information about licensed dispensaries in their area.
Our platform was built to bridge the gap between consumers and quality cannabis products,
making it easier than ever to find trusted dispensaries that meet your needs.
</p>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-16 bg-white">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">
Why Choose Us
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{features.map((feature, index) => (
<Card key={index}>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10">
<feature.icon className="h-6 w-6 text-primary" />
</div>
<div>
<h3 className="font-semibold text-gray-900 mb-2">{feature.title}</h3>
<p className="text-gray-600">{feature.description}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</section>
{/* How It Works */}
<section className="py-16 bg-gray-50">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold text-gray-900 text-center mb-12">
How It Works
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 max-w-4xl mx-auto">
{[
{ step: '1', title: 'Search', description: 'Enter your location or use "Near Me" to find dispensaries.' },
{ step: '2', title: 'Compare', description: 'View ratings, hours, and amenities to find the right fit.' },
{ step: '3', title: 'Visit', description: 'Get directions and contact info to plan your visit.' },
].map((item, index) => (
<div key={index} className="text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary text-white text-2xl font-bold mx-auto mb-4">
{item.step}
</div>
<h3 className="font-semibold text-gray-900 mb-2">{item.title}</h3>
<p className="text-gray-600">{item.description}</p>
</div>
))}
</div>
</div>
</section>
</div>
);
}
export default About;

View File

@@ -0,0 +1,197 @@
import React, { useState } from 'react';
import { Bell, Trash2, Plus, TrendingDown, MapPin } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Switch } from '../../components/ui/switch';
import { mockAlerts } from '../../mockData';
export function Alerts() {
const [alerts, setAlerts] = useState(mockAlerts);
const [showCreateModal, setShowCreateModal] = useState(false);
const toggleAlert = (id) => {
setAlerts(
alerts.map((alert) =>
alert.id === id ? { ...alert, isActive: !alert.isActive } : alert
)
);
};
const handleDelete = (id) => {
setAlerts(alerts.filter((a) => a.id !== id));
};
const activeCount = alerts.filter((a) => a.isActive).length;
return (
<div className="p-6">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Price Alerts</h1>
<p className="text-gray-600">
Get notified when prices drop on your favorite products
</p>
</div>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="h-4 w-4 mr-2" />
New Alert
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-gray-900">{alerts.length}</p>
<p className="text-sm text-gray-500">Total Alerts</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-primary">{activeCount}</p>
<p className="text-sm text-gray-500">Active</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-amber-500">
{alerts.filter((a) => a.currentPrice <= a.targetPrice).length}
</p>
<p className="text-sm text-gray-500">Triggered</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<p className="text-2xl font-bold text-green-500">$142</p>
<p className="text-sm text-gray-500">Total Saved</p>
</CardContent>
</Card>
</div>
{/* Alerts List */}
{alerts.length > 0 ? (
<div className="space-y-4">
{alerts.map((alert) => (
<Card
key={alert.id}
className={`transition-opacity ${!alert.isActive ? 'opacity-60' : ''}`}
>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div
className={`h-12 w-12 rounded-lg flex items-center justify-center ${
alert.currentPrice <= alert.targetPrice
? 'bg-green-100'
: 'bg-primary/10'
}`}
>
{alert.currentPrice <= alert.targetPrice ? (
<TrendingDown className="h-6 w-6 text-green-500" />
) : (
<Bell className="h-6 w-6 text-primary" />
)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{alert.productName}</h3>
{alert.currentPrice <= alert.targetPrice && (
<Badge variant="success">Price Alert!</Badge>
)}
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<MapPin className="h-3 w-3" />
<span>{alert.dispensary}</span>
</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<p className="text-sm text-gray-500">Target Price</p>
<p className="font-semibold text-primary">${alert.targetPrice}</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">Current Price</p>
<p
className={`font-semibold ${
alert.currentPrice <= alert.targetPrice
? 'text-green-500'
: 'text-gray-900'
}`}
>
${alert.currentPrice}
</p>
</div>
<div className="flex items-center gap-3 pl-4 border-l">
<Switch
checked={alert.isActive}
onCheckedChange={() => toggleAlert(alert.id)}
/>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-500"
onClick={() => handleDelete(alert.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="py-16 text-center">
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No price alerts</h3>
<p className="text-gray-600 mb-4">
Create alerts to get notified when prices drop on products you want
</p>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Alert
</Button>
</CardContent>
</Card>
)}
{/* Simple Create Modal - In production would be a proper modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Create Price Alert</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center py-8 text-gray-500">
<Bell className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>Alert creation would connect to the API</p>
<p className="text-sm mt-2">Search for a product and set your target price</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => setShowCreateModal(false)}
>
Cancel
</Button>
<Button className="flex-1" onClick={() => setShowCreateModal(false)}>
Create Alert
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
export default Alerts;

View File

@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import { Mail, Phone, MapPin, Send, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
export function Contact() {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: '',
});
const [submitted, setSubmitted] = useState(false);
const [expandedFaq, setExpandedFaq] = useState(null);
const handleSubmit = (e) => {
e.preventDefault();
// In real app, would submit to backend
console.log('Form submitted:', formData);
setSubmitted(true);
};
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const faqs = [
{
question: 'How do I find dispensaries near me?',
answer: 'Use the "Near Me" button on our homepage to automatically detect your location, or enter a city, zip code, or address in the search bar.',
},
{
question: 'Are all listed dispensaries licensed?',
answer: 'Yes, we only list state-licensed dispensaries. Our team verifies each listing to ensure they hold valid licenses.',
},
{
question: 'How do I set up price alerts?',
answer: 'Create a free account, then navigate to your Dashboard and click on "Alerts". You can set up alerts for specific dispensaries or product types.',
},
{
question: 'Is my personal information secure?',
answer: 'Absolutely. We use industry-standard encryption and never share your personal information with third parties.',
},
{
question: 'How can I suggest a dispensary to add?',
answer: 'Use the contact form on this page to submit a new dispensary suggestion. Please include the name, address, and website if available.',
},
];
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">Contact Us</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
Have questions or feedback? We'd love to hear from you. Send us a message
and we'll respond as soon as possible.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-6xl mx-auto">
{/* Contact Form */}
<div className="lg:col-span-2">
<Card>
<CardHeader>
<CardTitle>Send us a message</CardTitle>
</CardHeader>
<CardContent>
{submitted ? (
<div className="text-center py-8">
<div className="h-16 w-16 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Send className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-semibold mb-2">Message Sent!</h3>
<p className="text-gray-600">Thank you for reaching out. We'll get back to you within 24-48 hours.</p>
<Button className="mt-4" onClick={() => setSubmitted(false)}>
Send Another Message
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Your name"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="your@email.com"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="subject">Subject</Label>
<Input
id="subject"
name="subject"
value={formData.subject}
onChange={handleChange}
placeholder="What is this regarding?"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Your message..."
rows={5}
required
/>
</div>
<Button type="submit" className="w-full">
<Send className="h-4 w-4 mr-2" />
Send Message
</Button>
</form>
)}
</CardContent>
</Card>
</div>
{/* Contact Info Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Contact Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<Mail className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium">Email</p>
<a href="mailto:support@findadispo.com" className="text-gray-600 hover:text-primary">
support@findadispo.com
</a>
</div>
</div>
<div className="flex items-start gap-3">
<Phone className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium">Phone</p>
<p className="text-gray-600">(555) 123-4567</p>
</div>
</div>
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium">Business Hours</p>
<p className="text-gray-600">Mon-Fri: 9AM - 6PM PST</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
{/* FAQ Section */}
<div className="max-w-3xl mx-auto mt-16">
<h2 className="text-2xl font-bold text-gray-900 text-center mb-8">
Frequently Asked Questions
</h2>
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={index}>
<CardContent className="p-0">
<button
className="w-full p-4 text-left flex items-center justify-between"
onClick={() => setExpandedFaq(expandedFaq === index ? null : index)}
>
<span className="font-medium text-gray-900">{faq.question}</span>
{expandedFaq === index ? (
<ChevronUp className="h-5 w-5 text-gray-500" />
) : (
<ChevronDown className="h-5 w-5 text-gray-500" />
)}
</button>
{expandedFaq === index && (
<div className="px-4 pb-4 text-gray-600">
{faq.answer}
</div>
)}
</CardContent>
</Card>
))}
</div>
</div>
</div>
</div>
);
}
export default Contact;

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { Home, Search, Bell, User, LogOut, MapPin } from 'lucide-react';
import { Button } from '../../components/ui/button';
export function Dashboard() {
const navigate = useNavigate();
const handleLogout = () => {
localStorage.removeItem('isAuthenticated');
localStorage.removeItem('user');
navigate('/');
};
const navItems = [
{ to: '/dashboard', icon: Home, label: 'Overview', end: true },
{ to: '/dashboard/saved', icon: Search, label: 'Saved Searches' },
{ to: '/dashboard/alerts', icon: Bell, label: 'Price Alerts' },
{ to: '/dashboard/profile', icon: User, label: 'Profile' },
];
return (
<div className="min-h-screen bg-gray-50">
<div className="flex">
{/* Sidebar */}
<aside className="hidden md:flex flex-col w-64 bg-white border-r min-h-[calc(100vh-64px)] sticky top-16">
<div className="p-4 border-b">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium text-sm">Welcome back!</p>
<p className="text-xs text-gray-500">Manage your account</p>
</div>
</div>
</div>
<nav className="flex-1 p-4">
<ul className="space-y-1">
{navItems.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
isActive
? 'bg-primary text-white'
: 'text-gray-600 hover:bg-gray-100'
}`
}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
</li>
))}
</ul>
</nav>
<div className="p-4 border-t">
<Button
variant="ghost"
className="w-full justify-start text-gray-600 hover:text-red-600 hover:bg-red-50"
onClick={handleLogout}
>
<LogOut className="h-5 w-5 mr-3" />
Sign Out
</Button>
</div>
</aside>
{/* Mobile Navigation */}
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t z-50">
<nav className="flex justify-around">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`flex flex-col items-center py-3 px-4 ${
isActive ? 'text-primary' : 'text-gray-500'
}`
}
>
<item.icon className="h-5 w-5" />
<span className="text-xs mt-1">{item.label}</span>
</NavLink>
))}
</nav>
</div>
{/* Main Content */}
<main className="flex-1 pb-20 md:pb-0">
<Outlet />
</main>
</div>
</div>
);
}
export default Dashboard;

View File

@@ -0,0 +1,177 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { MapPin, Star, Clock, Search, Bell, TrendingDown, ArrowRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { mockSavedSearches, mockAlerts } from '../../mockData';
export function DashboardHome() {
const recentSearches = mockSavedSearches.slice(0, 3);
const activeAlerts = mockAlerts.filter((a) => a.isActive).slice(0, 3);
const stats = [
{ label: 'Saved Searches', value: mockSavedSearches.length, icon: Search },
{ label: 'Active Alerts', value: mockAlerts.filter((a) => a.isActive).length, icon: Bell },
{ label: 'Dispensaries Viewed', value: 24, icon: MapPin },
{ label: 'Deals Found', value: 8, icon: TrendingDown },
];
return (
<div className="p-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Welcome back! Here's your activity summary.</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
{stats.map((stat, index) => (
<Card key={index}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<stat.icon className="h-5 w-5 text-primary" />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
<p className="text-xs text-gray-500">{stat.label}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Recent Saved Searches */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Recent Saved Searches</CardTitle>
<Link to="/dashboard/saved">
<Button variant="ghost" size="sm">
View All <ArrowRight className="h-4 w-4 ml-1" />
</Button>
</Link>
</CardHeader>
<CardContent>
{recentSearches.length > 0 ? (
<div className="space-y-3">
{recentSearches.map((search) => (
<div
key={search.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Search className="h-4 w-4 text-gray-400" />
<div>
<p className="font-medium text-sm">{search.name}</p>
<p className="text-xs text-gray-500">{search.query}</p>
</div>
</div>
<Badge variant="outline">{search.results} results</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
<Search className="h-8 w-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm">No saved searches yet</p>
<Link to="/">
<Button variant="link" size="sm">
Start searching
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
{/* Active Alerts */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">Active Price Alerts</CardTitle>
<Link to="/dashboard/alerts">
<Button variant="ghost" size="sm">
View All <ArrowRight className="h-4 w-4 ml-1" />
</Button>
</Link>
</CardHeader>
<CardContent>
{activeAlerts.length > 0 ? (
<div className="space-y-3">
{activeAlerts.map((alert) => (
<div
key={alert.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Bell className="h-4 w-4 text-primary" />
<div>
<p className="font-medium text-sm">{alert.productName}</p>
<p className="text-xs text-gray-500">{alert.dispensary}</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-primary">
Under ${alert.targetPrice}
</p>
<p className="text-xs text-gray-500">Current: ${alert.currentPrice}</p>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
<Bell className="h-8 w-8 mx-auto mb-2 text-gray-300" />
<p className="text-sm">No active alerts</p>
<Link to="/dashboard/alerts">
<Button variant="link" size="sm">
Create an alert
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">Quick Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Link to="/">
<Button variant="outline" className="w-full h-auto py-4 flex-col">
<Search className="h-6 w-6 mb-2" />
<span>Find Dispensaries</span>
</Button>
</Link>
<Link to="/dashboard/saved">
<Button variant="outline" className="w-full h-auto py-4 flex-col">
<MapPin className="h-6 w-6 mb-2" />
<span>Saved Searches</span>
</Button>
</Link>
<Link to="/dashboard/alerts">
<Button variant="outline" className="w-full h-auto py-4 flex-col">
<Bell className="h-6 w-6 mb-2" />
<span>Price Alerts</span>
</Button>
</Link>
<Link to="/dashboard/profile">
<Button variant="outline" className="w-full h-auto py-4 flex-col">
<Star className="h-6 w-6 mb-2" />
<span>Edit Profile</span>
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}
export default DashboardHome;

View File

@@ -0,0 +1,228 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { MapPin, Phone, Clock, Star, Navigation, ArrowLeft, Share2, Heart, Loader2 } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { getDispensaryBySlug, mapDispensaryForUI } from '../../api/client';
import { formatDistance } from '../../lib/utils';
export function DispensaryDetail() {
const { slug } = useParams();
const [dispensary, setDispensary] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchDispensary = async () => {
try {
setLoading(true);
setError(null);
const result = await getDispensaryBySlug(slug);
setDispensary(mapDispensaryForUI(result));
} catch (err) {
console.error('Error fetching dispensary:', err);
setError('Failed to load dispensary details.');
} finally {
setLoading(false);
}
};
fetchDispensary();
}, [slug]);
if (loading) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<Loader2 className="h-16 w-16 mx-auto mb-4 animate-spin text-primary" />
<p className="text-gray-600">Loading dispensary details...</p>
</div>
);
}
if (error) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<MapPin className="h-16 w-16 mx-auto mb-4 text-red-300" />
<h1 className="text-2xl font-bold mb-4">Error Loading Dispensary</h1>
<p className="text-red-600 mb-6">{error}</p>
<Link to="/">
<Button>Back to Home</Button>
</Link>
</div>
);
}
if (!dispensary) {
return (
<div className="container mx-auto px-4 py-16 text-center">
<MapPin className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<h1 className="text-2xl font-bold mb-4">Dispensary Not Found</h1>
<p className="text-gray-600 mb-6">The dispensary you're looking for doesn't exist.</p>
<Link to="/">
<Button>Back to Home</Button>
</Link>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Hero Section */}
<div className="relative h-64 md:h-80 bg-gray-800">
<img
src={dispensary.image}
alt={dispensary.name}
className="w-full h-full object-cover opacity-60"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
{/* Back Button */}
<div className="absolute top-4 left-4">
<Link to="/">
<Button variant="ghost" className="text-white hover:bg-white/20">
<ArrowLeft className="h-5 w-5 mr-2" />
Back
</Button>
</Link>
</div>
{/* Action Buttons */}
<div className="absolute top-4 right-4 flex gap-2">
<Button variant="ghost" className="text-white hover:bg-white/20" size="icon">
<Share2 className="h-5 w-5" />
</Button>
<Button variant="ghost" className="text-white hover:bg-white/20" size="icon">
<Heart className="h-5 w-5" />
</Button>
</div>
{/* Title */}
<div className="absolute bottom-6 left-0 right-0 container mx-auto px-4">
<div className="flex items-center gap-3 mb-2">
<Badge variant={dispensary.isOpen ? 'success' : 'secondary'}>
{dispensary.isOpen ? 'Open Now' : 'Closed'}
</Badge>
<span className="text-white/80 text-sm">{formatDistance(dispensary.distance)} away</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-white">{dispensary.name}</h1>
</div>
</div>
{/* Content */}
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Quick Info */}
<Card>
<CardContent className="p-6">
<div className="flex flex-wrap gap-6">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1 text-amber-500">
<Star className="h-6 w-6 fill-current" />
<span className="text-2xl font-bold">{dispensary.rating}</span>
</div>
<span className="text-gray-500">({dispensary.reviews} reviews)</span>
</div>
<div className="flex items-center gap-2 text-gray-600">
<Clock className="h-5 w-5" />
<span>{dispensary.hours}</span>
</div>
</div>
</CardContent>
</Card>
{/* Description */}
<Card>
<CardHeader>
<CardTitle>About</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600">{dispensary.description}</p>
</CardContent>
</Card>
{/* Amenities */}
{dispensary.amenities && dispensary.amenities.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Amenities</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{dispensary.amenities.map((amenity, index) => (
<Badge key={index} variant="outline">{amenity}</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Products Section Placeholder */}
<Card>
<CardHeader>
<CardTitle>Available Products</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-gray-500">
<p>Product menu coming soon</p>
<p className="text-sm mt-2">Connect to API to view available products</p>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Contact Card */}
<Card>
<CardHeader>
<CardTitle>Contact</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-gray-900">{dispensary.address}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Phone className="h-5 w-5 text-gray-400" />
<a href={`tel:${dispensary.phone}`} className="text-primary hover:underline">
{dispensary.phone}
</a>
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="space-y-3">
<Button className="w-full" size="lg">
<Phone className="h-5 w-5 mr-2" />
Call Now
</Button>
<Button variant="outline" className="w-full" size="lg">
<Navigation className="h-5 w-5 mr-2" />
Get Directions
</Button>
</div>
{/* Map Placeholder */}
<Card>
<CardContent className="p-0">
<div className="h-48 bg-gray-200 rounded-lg flex items-center justify-center">
<div className="text-center text-gray-500">
<MapPin className="h-8 w-8 mx-auto mb-2" />
<p className="text-sm">Map View</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
);
}
export default DispensaryDetail;

View File

@@ -0,0 +1,301 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Search, MapPin, Navigation, Star, Clock, Phone, ExternalLink, Filter, ChevronDown, Loader2 } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Card, CardContent } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { searchDispensaries } from '../../api/client';
import { formatDistance } from '../../lib/utils';
export function Home() {
const [searchQuery, setSearchQuery] = useState('');
const [dispensaries, setDispensaries] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [selectedDispensary, setSelectedDispensary] = useState(null);
const [filters, setFilters] = useState({
openNow: false,
minRating: 0,
maxDistance: 100,
});
const [showFilters, setShowFilters] = useState(false);
// Fetch dispensaries from API
useEffect(() => {
const fetchDispensaries = async () => {
try {
setLoading(true);
setError(null);
const results = await searchDispensaries(searchQuery, filters);
setDispensaries(results);
} catch (err) {
console.error('Error fetching dispensaries:', err);
setError('Failed to load dispensaries. Please try again.');
} finally {
setLoading(false);
}
};
fetchDispensaries();
}, [searchQuery, filters]);
const handleNearMe = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
// In real app, would filter/sort by distance from user
console.log('User location:', position.coords);
setSearchQuery('Near me');
},
(error) => {
console.error('Geolocation error:', error);
alert('Unable to get your location. Please enable location services.');
}
);
} else {
alert('Geolocation is not supported by your browser');
}
};
const handleSearch = (e) => {
e.preventDefault();
// Search is already handled by useEffect
};
return (
<div className="flex flex-col h-[calc(100vh-64px)]">
{/* Search Header */}
<div className="bg-white border-b px-4 py-4">
<div className="container mx-auto">
<form onSubmit={handleSearch} className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search by city, zip code, or dispensary name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button
type="button"
variant="outline"
onClick={handleNearMe}
className="flex items-center gap-2"
>
<Navigation className="h-4 w-4" />
Near Me
</Button>
<Button
type="button"
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
Filters
<ChevronDown className={`h-4 w-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</Button>
<Button type="submit">Search</Button>
</form>
{/* Filter Panel */}
{showFilters && (
<div className="mt-4 p-4 bg-gray-50 rounded-lg flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.openNow}
onChange={(e) => setFilters({ ...filters, openNow: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm">Open Now</span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm">Min Rating:</span>
<select
value={filters.minRating}
onChange={(e) => setFilters({ ...filters, minRating: Number(e.target.value) })}
className="text-sm border rounded px-2 py-1"
>
<option value="0">Any</option>
<option value="3">3+ Stars</option>
<option value="4">4+ Stars</option>
<option value="4.5">4.5+ Stars</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">Max Distance:</span>
<select
value={filters.maxDistance}
onChange={(e) => setFilters({ ...filters, maxDistance: Number(e.target.value) })}
className="text-sm border rounded px-2 py-1"
>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
<option value="25">25 miles</option>
<option value="50">50 miles</option>
<option value="100">Any distance</option>
</select>
</div>
</div>
)}
</div>
</div>
{/* Main Content - Split View */}
<div className="flex-1 flex overflow-hidden">
{/* Dispensary List (Left Side) */}
<div className="w-full md:w-2/5 overflow-y-auto border-r bg-gray-50">
<div className="p-4 border-b bg-white">
<p className="text-sm text-gray-600">
{dispensaries.length} dispensaries found
</p>
</div>
<div className="divide-y">
{loading ? (
<div className="p-8 text-center">
<Loader2 className="h-8 w-8 mx-auto mb-4 animate-spin text-primary" />
<p className="text-gray-500">Loading dispensaries...</p>
</div>
) : error ? (
<div className="p-8 text-center text-red-500">
<p>{error}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => setFilters({...filters})}>
Retry
</Button>
</div>
) : dispensaries.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<MapPin className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No dispensaries found</p>
<p className="text-sm">Try adjusting your search or filters</p>
</div>
) : (
dispensaries.map((dispensary) => (
<DispensaryCard
key={dispensary.id}
dispensary={dispensary}
isSelected={selectedDispensary?.id === dispensary.id}
onClick={() => setSelectedDispensary(dispensary)}
/>
))
)}
</div>
</div>
{/* Map (Right Side) */}
<div className="hidden md:flex flex-1 relative bg-gray-200">
{/* Placeholder for Google Maps */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<MapPin className="h-16 w-16 mx-auto mb-4 text-primary/30" />
<p className="text-lg font-medium">Map View</p>
<p className="text-sm">Google Maps will be integrated here</p>
<p className="text-xs mt-2 text-gray-400">Configure API key in admin panel</p>
</div>
</div>
{/* Selected Dispensary Card Overlay */}
{selectedDispensary && (
<div className="absolute bottom-4 left-4 right-4 max-w-md">
<Card className="shadow-lg">
<CardContent className="p-4">
<div className="flex gap-4">
<img
src={selectedDispensary.image}
alt={selectedDispensary.name}
className="w-20 h-20 rounded-lg object-cover"
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold truncate">{selectedDispensary.name}</h3>
<div className="flex items-center gap-1 text-sm text-amber-500">
<Star className="h-4 w-4 fill-current" />
<span>{selectedDispensary.rating}</span>
<span className="text-gray-400">({selectedDispensary.reviews})</span>
</div>
<p className="text-sm text-gray-500 truncate">{selectedDispensary.address}</p>
<div className="mt-2 flex gap-2">
<Link to={`/dispensary/${selectedDispensary.slug}`}>
<Button size="sm">View Menu</Button>
</Link>
<Button size="sm" variant="outline">
<Navigation className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</div>
</div>
);
}
// Dispensary Card Component
function DispensaryCard({ dispensary, isSelected, onClick }) {
return (
<div
className={`p-4 bg-white cursor-pointer hover:bg-gray-50 transition-colors ${
isSelected ? 'bg-primary/5 border-l-4 border-l-primary' : ''
}`}
onClick={onClick}
>
<div className="flex gap-4">
<img
src={dispensary.image}
alt={dispensary.name}
className="w-24 h-24 rounded-lg object-cover flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-gray-900 truncate">{dispensary.name}</h3>
<Badge variant={dispensary.isOpen ? 'success' : 'secondary'} className="flex-shrink-0">
{dispensary.isOpen ? 'Open' : 'Closed'}
</Badge>
</div>
<div className="flex items-center gap-2 mt-1">
<div className="flex items-center gap-1 text-amber-500">
<Star className="h-4 w-4 fill-current" />
<span className="text-sm font-medium">{dispensary.rating}</span>
</div>
<span className="text-sm text-gray-400">({dispensary.reviews} reviews)</span>
<span className="text-sm text-gray-400">|</span>
<span className="text-sm text-gray-500">{formatDistance(dispensary.distance)}</span>
</div>
<div className="flex items-center gap-1 mt-1 text-sm text-gray-500">
<Clock className="h-3.5 w-3.5" />
<span>{dispensary.hours}</span>
</div>
<p className="text-sm text-gray-500 truncate mt-1">
<MapPin className="h-3.5 w-3.5 inline mr-1" />
{dispensary.address}
</p>
<div className="flex gap-2 mt-3">
<Link to={`/dispensary/${dispensary.slug}`}>
<Button size="sm" className="h-8">View Menu</Button>
</Link>
<Button size="sm" variant="outline" className="h-8">
<Phone className="h-3.5 w-3.5 mr-1" />
Call
</Button>
<Button size="sm" variant="outline" className="h-8">
<ExternalLink className="h-3.5 w-3.5 mr-1" />
Directions
</Button>
</div>
</div>
</div>
</div>
);
}
export default Home;

View File

@@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { MapPin, Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
export function Login() {
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: '',
rememberMe: false,
});
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value,
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// For demo purposes, accept any email/password
if (formData.email && formData.password) {
// In real app, would call auth API and store JWT
localStorage.setItem('isAuthenticated', 'true');
localStorage.setItem('user', JSON.stringify({ email: formData.email }));
navigate('/dashboard');
} else {
setError('Please fill in all fields');
}
setLoading(false);
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center space-x-2">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
<MapPin className="h-7 w-7 text-white" />
</div>
<span className="text-2xl font-bold text-gray-900">Find a Dispensary</span>
</Link>
</div>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl">Welcome Back</CardTitle>
<p className="text-gray-600 mt-2">Sign in to your account</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">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
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="you@example.com"
className="pl-10"
required
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link to="/forgot-password" className="text-sm text-primary hover:underline">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
placeholder="Enter your password"
className="pl-10 pr-10"
required
/>
<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>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="rememberMe"
name="rememberMe"
checked={formData.rememberMe}
onChange={handleChange}
className="rounded border-gray-300"
/>
<Label htmlFor="rememberMe" className="text-sm font-normal cursor-pointer">
Remember me for 30 days
</Label>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-4 text-gray-500">Or continue with</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button variant="outline" className="w-full">
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</Button>
<Button variant="outline" className="w-full">
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z" />
</svg>
GitHub
</Button>
</div>
</div>
<p className="mt-6 text-center text-sm text-gray-600">
Don't have an account?{' '}
<Link to="/signup" className="text-primary font-medium hover:underline">
Sign up for free
</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}
export default Login;

View File

@@ -0,0 +1,246 @@
import React, { useState } from 'react';
import { User, Mail, MapPin, Bell, Shield, Save } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Switch } from '../../components/ui/switch';
import { Separator } from '../../components/ui/separator';
export function Profile() {
const [profile, setProfile] = useState({
name: 'John Doe',
email: 'john.doe@example.com',
phone: '(555) 123-4567',
location: 'Phoenix, AZ',
});
const [notifications, setNotifications] = useState({
priceAlerts: true,
newDispensaries: false,
weeklyDigest: true,
promotions: false,
});
const [saved, setSaved] = useState(false);
const handleProfileChange = (e) => {
setProfile({ ...profile, [e.target.name]: e.target.value });
setSaved(false);
};
const handleNotificationChange = (key, value) => {
setNotifications({ ...notifications, [key]: value });
setSaved(false);
};
const handleSave = () => {
// In real app, would save to API
console.log('Saving profile:', profile);
console.log('Saving notifications:', notifications);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
};
return (
<div className="p-6 max-w-3xl">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Profile Settings</h1>
<p className="text-gray-600">Manage your account settings and preferences</p>
</div>
{/* Profile Information */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
Personal Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-6">
<div className="h-20 w-20 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-10 w-10 text-primary" />
</div>
<div>
<Button variant="outline" size="sm">
Change Photo
</Button>
<p className="text-xs text-gray-500 mt-1">JPG, PNG. Max 2MB</p>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Full Name</Label>
<Input
id="name"
name="name"
value={profile.name}
onChange={handleProfileChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
name="email"
type="email"
value={profile.email}
onChange={handleProfileChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input
id="phone"
name="phone"
type="tel"
value={profile.phone}
onChange={handleProfileChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="location">Default Location</Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="location"
name="location"
value={profile.location}
onChange={handleProfileChange}
className="pl-10"
/>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Notification Preferences */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notification Preferences
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Price Alerts</p>
<p className="text-sm text-gray-500">
Get notified when prices drop on watched products
</p>
</div>
<Switch
checked={notifications.priceAlerts}
onCheckedChange={(value) => handleNotificationChange('priceAlerts', value)}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">New Dispensaries</p>
<p className="text-sm text-gray-500">
Get notified when new dispensaries open near you
</p>
</div>
<Switch
checked={notifications.newDispensaries}
onCheckedChange={(value) => handleNotificationChange('newDispensaries', value)}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Weekly Digest</p>
<p className="text-sm text-gray-500">
Receive a weekly summary of deals in your area
</p>
</div>
<Switch
checked={notifications.weeklyDigest}
onCheckedChange={(value) => handleNotificationChange('weeklyDigest', value)}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Promotions & Tips</p>
<p className="text-sm text-gray-500">
Receive tips and promotional offers
</p>
</div>
<Switch
checked={notifications.promotions}
onCheckedChange={(value) => handleNotificationChange('promotions', value)}
/>
</div>
</CardContent>
</Card>
{/* Security */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Security
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Password</p>
<p className="text-sm text-gray-500">Last changed 3 months ago</p>
</div>
<Button variant="outline">Change Password</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Two-Factor Authentication</p>
<p className="text-sm text-gray-500">Add an extra layer of security</p>
</div>
<Button variant="outline">Enable</Button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-red-600">Delete Account</p>
<p className="text-sm text-gray-500">
Permanently delete your account and all data
</p>
</div>
<Button variant="outline" className="text-red-600 hover:bg-red-50">
Delete
</Button>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex items-center gap-4">
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
Save Changes
</Button>
{saved && <span className="text-sm text-green-600">Changes saved successfully!</span>}
</div>
</div>
);
}
export default Profile;

View File

@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Search, Trash2, ExternalLink, Clock, MapPin } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { mockSavedSearches } from '../../mockData';
export function SavedSearches() {
const [searches, setSearches] = useState(mockSavedSearches);
const handleDelete = (id) => {
setSearches(searches.filter((s) => s.id !== id));
};
return (
<div className="p-6">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Saved Searches</h1>
<p className="text-gray-600">Quickly access your frequently used searches</p>
</div>
<Link to="/">
<Button>
<Search className="h-4 w-4 mr-2" />
New Search
</Button>
</Link>
</div>
{searches.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{searches.map((search) => (
<Card key={search.id} className="hover:shadow-md transition-shadow">
<CardContent className="p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<Search className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold text-gray-900">{search.name}</h3>
<p className="text-xs text-gray-500">{search.query}</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-gray-400 hover:text-red-500"
onClick={() => handleDelete(search.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{search.lastUsed}</span>
</div>
<div className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
<span>{search.results} results</span>
</div>
</div>
{search.filters && search.filters.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{search.filters.map((filter, index) => (
<Badge key={index} variant="outline" className="text-xs">
{filter}
</Badge>
))}
</div>
)}
<Link to={`/?q=${encodeURIComponent(search.query)}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="h-4 w-4 mr-2" />
Run Search
</Button>
</Link>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="py-16 text-center">
<Search className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No saved searches</h3>
<p className="text-gray-600 mb-4">
Save your frequent searches for quick access
</p>
<Link to="/">
<Button>Start Searching</Button>
</Link>
</CardContent>
</Card>
)}
</div>
);
}
export default SavedSearches;

View File

@@ -0,0 +1,306 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { MapPin, Mail, Lock, Eye, EyeOff, User, Check } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
export function Signup() {
const navigate = useNavigate();
const [showPassword, setShowPassword] = useState(false);
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
agreeToTerms: false,
subscribeNewsletter: true,
});
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const passwordRequirements = [
{ label: 'At least 8 characters', test: (p) => p.length >= 8 },
{ label: 'Contains a number', test: (p) => /\d/.test(p) },
{ label: 'Contains uppercase letter', test: (p) => /[A-Z]/.test(p) },
];
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value,
});
// Clear errors on change
if (errors[name]) {
setErrors({ ...errors, [name]: '' });
}
};
const validate = () => {
const newErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Please enter a valid email';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
}
if (!formData.agreeToTerms) {
newErrors.agreeToTerms = 'You must agree to the terms';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// In real app, would call registration API
localStorage.setItem('isAuthenticated', 'true');
localStorage.setItem('user', JSON.stringify({ email: formData.email, name: formData.name }));
navigate('/dashboard');
setLoading(false);
};
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center py-12 px-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link to="/" className="inline-flex items-center space-x-2">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary">
<MapPin className="h-7 w-7 text-white" />
</div>
<span className="text-2xl font-bold text-gray-900">Find a Dispensary</span>
</Link>
</div>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-2xl">Create Your Account</CardTitle>
<p className="text-gray-600 mt-2">Start finding dispensaries near you</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Full 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
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
placeholder="John Doe"
className={`pl-10 ${errors.name ? 'border-red-500' : ''}`}
/>
</div>
{errors.name && <p className="text-red-500 text-xs">{errors.name}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="email">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
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="you@example.com"
className={`pl-10 ${errors.email ? 'border-red-500' : ''}`}
/>
</div>
{errors.email && <p className="text-red-500 text-xs">{errors.email}</p>}
</div>
<div className="space-y-2">
<Label htmlFor="password">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
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
value={formData.password}
onChange={handleChange}
placeholder="Create a password"
className={`pl-10 pr-10 ${errors.password ? 'border-red-500' : ''}`}
/>
<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>
{errors.password && <p className="text-red-500 text-xs">{errors.password}</p>}
{/* Password Requirements */}
<div className="space-y-1 mt-2">
{passwordRequirements.map((req, index) => (
<div key={index} className="flex items-center gap-2 text-xs">
<Check
className={`h-3 w-3 ${
req.test(formData.password) ? 'text-green-500' : 'text-gray-300'
}`}
/>
<span
className={
req.test(formData.password) ? 'text-green-600' : 'text-gray-500'
}
>
{req.label}
</span>
</div>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm 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
id="confirmPassword"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
placeholder="Confirm your password"
className={`pl-10 ${errors.confirmPassword ? 'border-red-500' : ''}`}
/>
</div>
{errors.confirmPassword && (
<p className="text-red-500 text-xs">{errors.confirmPassword}</p>
)}
</div>
<div className="space-y-3">
<div className="flex items-start gap-2">
<input
type="checkbox"
id="agreeToTerms"
name="agreeToTerms"
checked={formData.agreeToTerms}
onChange={handleChange}
className="rounded border-gray-300 mt-1"
/>
<Label htmlFor="agreeToTerms" className="text-sm font-normal cursor-pointer">
I agree to the{' '}
<Link to="/terms" className="text-primary hover:underline">
Terms of Service
</Link>{' '}
and{' '}
<Link to="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
</Label>
</div>
{errors.agreeToTerms && (
<p className="text-red-500 text-xs">{errors.agreeToTerms}</p>
)}
<div className="flex items-start gap-2">
<input
type="checkbox"
id="subscribeNewsletter"
name="subscribeNewsletter"
checked={formData.subscribeNewsletter}
onChange={handleChange}
className="rounded border-gray-300 mt-1"
/>
<Label htmlFor="subscribeNewsletter" className="text-sm font-normal cursor-pointer">
Send me updates about deals and new dispensaries in my area
</Label>
</div>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Creating account...' : 'Create Account'}
</Button>
</form>
<div className="mt-6">
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-4 text-gray-500">Or sign up with</span>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<Button variant="outline" className="w-full">
<svg className="h-5 w-5 mr-2" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</Button>
<Button variant="outline" className="w-full">
<svg className="h-5 w-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.161 22 16.416 22 12c0-5.523-4.477-10-10-10z" />
</svg>
GitHub
</Button>
</div>
</div>
<p className="mt-6 text-center text-sm text-gray-600">
Already have an account?{' '}
<Link to="/login" className="text-primary font-medium hover:underline">
Sign in
</Link>
</p>
</CardContent>
</Card>
</div>
</div>
);
}
export default Signup;

View File

@@ -0,0 +1,321 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Search, MapPin, Navigation, Star, Clock, Phone, Filter, ChevronDown, List, Map, Loader2 } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Card, CardContent } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { searchDispensaries } from '../../api/client';
import { formatDistance } from '../../lib/utils';
export function StoreLocator() {
const [searchQuery, setSearchQuery] = useState('');
const [dispensaries, setDispensaries] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [viewMode, setViewMode] = useState('list'); // 'list' or 'map'
const [filters, setFilters] = useState({
openNow: false,
minRating: 0,
maxDistance: 100,
amenities: [],
});
const [showFilters, setShowFilters] = useState(false);
const amenityOptions = ['Wheelchair Accessible', 'ATM', 'Online Ordering', 'Curbside Pickup', 'Delivery'];
// Fetch dispensaries from API
useEffect(() => {
const fetchDispensaries = async () => {
try {
setLoading(true);
setError(null);
const results = await searchDispensaries(searchQuery, filters);
setDispensaries(results);
} catch (err) {
console.error('Error fetching dispensaries:', err);
setError('Failed to load dispensaries. Please try again.');
} finally {
setLoading(false);
}
};
fetchDispensaries();
}, [searchQuery, filters]);
const handleNearMe = () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(position) => {
console.log('User location:', position.coords);
setSearchQuery('Near me');
},
(error) => {
console.error('Geolocation error:', error);
alert('Unable to get your location. Please enable location services.');
}
);
} else {
alert('Geolocation is not supported by your browser');
}
};
const toggleAmenity = (amenity) => {
setFilters((prev) => ({
...prev,
amenities: prev.amenities.includes(amenity)
? prev.amenities.filter((a) => a !== amenity)
: [...prev.amenities, amenity],
}));
};
const clearFilters = () => {
setFilters({
openNow: false,
minRating: 0,
maxDistance: 100,
amenities: [],
});
setSearchQuery('');
};
const activeFilterCount = [
filters.openNow,
filters.minRating > 0,
filters.maxDistance < 100,
filters.amenities.length > 0,
].filter(Boolean).length;
return (
<div className="min-h-screen bg-gray-50">
{/* Search Header */}
<div className="bg-white shadow-sm sticky top-16 z-40">
<div className="container mx-auto px-4 py-4">
<div className="flex flex-col gap-4">
{/* Search Bar */}
<div className="flex gap-2 flex-wrap">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search by city, zip code, or dispensary name..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline" onClick={handleNearMe} className="flex items-center gap-2">
<Navigation className="h-4 w-4" />
Near Me
</Button>
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<Badge variant="primary" className="ml-1">
{activeFilterCount}
</Badge>
)}
<ChevronDown className={`h-4 w-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</Button>
</div>
{/* View Toggle */}
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">{dispensaries.length} dispensaries found</p>
<div className="flex gap-1 bg-gray-100 rounded-lg p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
className="flex items-center gap-1"
>
<List className="h-4 w-4" />
List
</Button>
<Button
variant={viewMode === 'map' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('map')}
className="flex items-center gap-1"
>
<Map className="h-4 w-4" />
Map
</Button>
</div>
</div>
{/* Filter Panel */}
{showFilters && (
<div className="p-4 bg-gray-50 rounded-lg border">
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.openNow}
onChange={(e) => setFilters({ ...filters, openNow: e.target.checked })}
className="rounded border-gray-300"
/>
<span className="text-sm font-medium">Open Now</span>
</label>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Min Rating:</span>
<select
value={filters.minRating}
onChange={(e) => setFilters({ ...filters, minRating: Number(e.target.value) })}
className="text-sm border rounded px-2 py-1"
>
<option value="0">Any</option>
<option value="3">3+ Stars</option>
<option value="4">4+ Stars</option>
<option value="4.5">4.5+ Stars</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Distance:</span>
<select
value={filters.maxDistance}
onChange={(e) => setFilters({ ...filters, maxDistance: Number(e.target.value) })}
className="text-sm border rounded px-2 py-1"
>
<option value="5">5 miles</option>
<option value="10">10 miles</option>
<option value="25">25 miles</option>
<option value="50">50 miles</option>
<option value="100">Any distance</option>
</select>
</div>
</div>
{/* Amenities */}
<div className="mt-4">
<span className="text-sm font-medium block mb-2">Amenities:</span>
<div className="flex flex-wrap gap-2">
{amenityOptions.map((amenity) => (
<Badge
key={amenity}
variant={filters.amenities.includes(amenity) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => toggleAmenity(amenity)}
>
{amenity}
</Badge>
))}
</div>
</div>
{activeFilterCount > 0 && (
<Button variant="ghost" size="sm" onClick={clearFilters} className="mt-4">
Clear all filters
</Button>
)}
</div>
)}
</div>
</div>
</div>
{/* Content */}
<div className="container mx-auto px-4 py-6">
{viewMode === 'list' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{loading ? (
<div className="col-span-full py-16 text-center">
<Loader2 className="h-16 w-16 mx-auto mb-4 animate-spin text-primary" />
<p className="text-gray-600">Loading dispensaries...</p>
</div>
) : error ? (
<div className="col-span-full py-16 text-center">
<MapPin className="h-16 w-16 mx-auto mb-4 text-red-300" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Error loading dispensaries</h3>
<p className="text-red-600 mb-4">{error}</p>
<Button variant="outline" onClick={() => setFilters({...filters})}>
Retry
</Button>
</div>
) : dispensaries.length === 0 ? (
<div className="col-span-full py-16 text-center">
<MapPin className="h-16 w-16 mx-auto mb-4 text-gray-300" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No dispensaries found</h3>
<p className="text-gray-600 mb-4">Try adjusting your search or filters</p>
<Button variant="outline" onClick={clearFilters}>
Clear filters
</Button>
</div>
) : (
dispensaries.map((dispensary) => (
<DispensaryGridCard key={dispensary.id} dispensary={dispensary} />
))
)}
</div>
) : (
<div className="relative h-[calc(100vh-280px)] bg-gray-200 rounded-lg flex items-center justify-center">
<div className="text-center text-gray-500">
<MapPin className="h-16 w-16 mx-auto mb-4 text-primary/30" />
<p className="text-lg font-medium">Map View</p>
<p className="text-sm">Google Maps will be integrated here</p>
<p className="text-xs mt-2 text-gray-400">Configure API key in admin panel</p>
</div>
</div>
)}
</div>
</div>
);
}
function DispensaryGridCard({ dispensary }) {
return (
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<div className="relative h-40">
<img src={dispensary.image} alt={dispensary.name} className="w-full h-full object-cover" />
<Badge
variant={dispensary.isOpen ? 'success' : 'secondary'}
className="absolute top-3 right-3"
>
{dispensary.isOpen ? 'Open' : 'Closed'}
</Badge>
</div>
<CardContent className="p-4">
<h3 className="font-semibold text-gray-900 truncate">{dispensary.name}</h3>
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1 text-amber-500">
<Star className="h-4 w-4 fill-current" />
<span className="text-sm font-medium">{dispensary.rating}</span>
</div>
<span className="text-sm text-gray-400">({dispensary.reviews})</span>
<span className="text-sm text-gray-400">|</span>
<span className="text-sm text-gray-500">{formatDistance(dispensary.distance)}</span>
</div>
<div className="flex items-center gap-1 mt-2 text-sm text-gray-500">
<Clock className="h-3.5 w-3.5" />
<span>{dispensary.hours}</span>
</div>
<p className="text-sm text-gray-500 truncate mt-1">
<MapPin className="h-3.5 w-3.5 inline mr-1" />
{dispensary.address}
</p>
<div className="flex gap-2 mt-4">
<Link to={`/dispensary/${dispensary.slug}`} className="flex-1">
<Button size="sm" className="w-full">
View Details
</Button>
</Link>
<Button size="sm" variant="outline">
<Phone className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
);
}
export default StoreLocator;

View File

@@ -0,0 +1,94 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./src/**/*.{js,jsx,ts,tsx}',
'./public/index.html',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "#10B981",
foreground: "#ffffff",
50: "#ECFDF5",
100: "#D1FAE5",
200: "#A7F3D0",
300: "#6EE7B7",
400: "#34D399",
500: "#10B981",
600: "#059669",
700: "#047857",
800: "#065F46",
900: "#064E3B",
},
secondary: {
DEFAULT: "#3B82F6",
foreground: "#ffffff",
50: "#EFF6FF",
100: "#DBEAFE",
200: "#BFDBFE",
300: "#93C5FD",
400: "#60A5FA",
500: "#3B82F6",
600: "#2563EB",
700: "#1D4ED8",
800: "#1E40AF",
900: "#1E3A8A",
},
accent: {
DEFAULT: "#F59E0B",
foreground: "#ffffff",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}