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

229
CLAUDE.md
View File

@@ -1,5 +1,136 @@
## Claude Guidelines for this Project
### Multi-Site Architecture (CRITICAL)
This project has **5 working locations** - always clarify which one before making changes:
| Folder | Domain | Type | Purpose |
|--------|--------|------|---------|
| `backend/` | (shared) | Express API | Single backend serving all frontends |
| `frontend/` | dispos.crawlsy.com | React SPA (Vite) | Legacy admin dashboard (internal use) |
| `cannaiq/` | cannaiq.co | React SPA + PWA | NEW admin dashboard / B2B analytics |
| `findadispo/` | findadispo.com | React SPA + PWA | Consumer dispensary finder |
| `findagram/` | findagram.co | React SPA + PWA | Consumer delivery marketplace |
**IMPORTANT: `frontend/` vs `cannaiq/` confusion:**
- `frontend/` = OLD/legacy dashboard design, deployed to `dispos.crawlsy.com` (internal admin)
- `cannaiq/` = NEW dashboard design, deployed to `cannaiq.co` (customer-facing B2B)
- These are DIFFERENT codebases - do NOT confuse them!
**Before any frontend work, ASK: "Which site? cannaiq, findadispo, findagram, or legacy (frontend/)?"**
All four frontends share:
- Same backend API (port 3010)
- Same PostgreSQL database
- Same Kubernetes deployment for backend
Each frontend has:
- Its own folder, package.json, Dockerfile
- Its own domain and branding
- Its own PWA manifest and service worker (cannaiq, findadispo, findagram)
- Separate Docker containers in production
---
### Multi-Domain Hosting Architecture
All three frontends are served from the **same IP** using **host-based routing**:
**Kubernetes Ingress (Production):**
```yaml
# Each domain routes to its own frontend service
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: multi-site-ingress
spec:
rules:
- host: cannaiq.co
http:
paths:
- path: /
backend:
service:
name: cannaiq-frontend
port: 80
- path: /api
backend:
service:
name: scraper # shared backend
port: 3010
- host: findadispo.com
http:
paths:
- path: /
backend:
service:
name: findadispo-frontend
port: 80
- path: /api
backend:
service:
name: scraper
port: 3010
- host: findagram.co
http:
paths:
- path: /
backend:
service:
name: findagram-frontend
port: 80
- path: /api
backend:
service:
name: scraper
port: 3010
```
**Key Points:**
- DNS A records for all 3 domains point to same IP
- Ingress controller routes based on `Host` header
- Each frontend is a separate Docker container (nginx serving static files)
- All frontends share the same backend API at `/api/*`
- SSL/TLS handled at ingress level (cert-manager)
---
### PWA Setup Requirements
Each frontend is a **Progressive Web App (PWA)**. Required files in each `public/` folder:
1. **manifest.json** - App metadata, icons, theme colors
2. **service-worker.js** - Offline caching, background sync
3. **Icons** - 192x192 and 512x512 PNG icons
**Vite PWA Plugin Setup** (in each frontend's vite.config.ts):
```typescript
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'Site Name',
short_name: 'Short',
theme_color: '#10b981',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' }
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}']
}
})
]
})
```
---
### Core Rules Summary
- **DB**: Use the single consolidated DB (CRAWLSY_DATABASE_URL → DATABASE_URL); no dual pools; schema_migrations must exist; apply migrations 031/032/033.
@@ -293,3 +424,101 @@
```bash
pkill -f "port-forward.*dispensary-scraper"
```
25) **Frontend Architecture - AVOID OVER-ENGINEERING**
**Key Principles:**
- **ONE BACKEND** serves ALL domains (cannaiq.co, findadispo.com, findagram.co)
- Do NOT create separate backend services for each domain
- The existing `dispensary-scraper` backend handles everything
**Frontend Build Differences:**
- `frontend/` uses **Vite** (outputs to `dist/`, uses `VITE_` env vars) → dispos.crawlsy.com (legacy)
- `cannaiq/` uses **Vite** (outputs to `dist/`, uses `VITE_` env vars) → cannaiq.co (NEW)
- `findadispo/` uses **Create React App** (outputs to `build/`, uses `REACT_APP_` env vars) → findadispo.com
- `findagram/` uses **Create React App** (outputs to `build/`, uses `REACT_APP_` env vars) → findagram.co
**CRA vs Vite Dockerfile Differences:**
```dockerfile
# Vite (frontend, cannaiq)
ENV VITE_API_URL=https://api.domain.com
RUN npm run build
COPY --from=builder /app/dist /usr/share/nginx/html
# CRA (findadispo, findagram)
ENV REACT_APP_API_URL=https://api.domain.com
RUN npm run build
COPY --from=builder /app/build /usr/share/nginx/html
```
**lucide-react Icon Gotchas:**
- Not all icons exist in older versions (e.g., `Cannabis` doesn't exist)
- Use `Leaf` as a substitute for cannabis-related icons
- When doing search/replace for icon names, be careful not to replace text content
- Example: "Cannabis-infused food" should NOT become "Leaf-infused food"
**Deployment Options:**
1. **Separate containers** (current): Each frontend in its own nginx container
2. **Single container** (better): One nginx with multi-domain config serving all frontends
**Single Container Multi-Domain Approach:**
```dockerfile
# Build all frontends
FROM node:20-slim AS builder-cannaiq
WORKDIR /app/cannaiq
COPY cannaiq/package*.json ./
RUN npm install
COPY cannaiq/ ./
RUN npm run build
FROM node:20-slim AS builder-findadispo
WORKDIR /app/findadispo
COPY findadispo/package*.json ./
RUN npm install
COPY findadispo/ ./
RUN npm run build
FROM node:20-slim AS builder-findagram
WORKDIR /app/findagram
COPY findagram/package*.json ./
RUN npm install
COPY findagram/ ./
RUN npm run build
# Production nginx with multi-domain routing
FROM nginx:alpine
COPY --from=builder-cannaiq /app/cannaiq/dist /var/www/cannaiq
COPY --from=builder-findadispo /app/findadispo/dist /var/www/findadispo
COPY --from=builder-findagram /app/findagram/build /var/www/findagram
COPY nginx-multi-domain.conf /etc/nginx/conf.d/default.conf
```
**nginx-multi-domain.conf:**
```nginx
server {
listen 80;
server_name cannaiq.co www.cannaiq.co;
root /var/www/cannaiq;
location / { try_files $uri $uri/ /index.html; }
}
server {
listen 80;
server_name findadispo.com www.findadispo.com;
root /var/www/findadispo;
location / { try_files $uri $uri/ /index.html; }
}
server {
listen 80;
server_name findagram.co www.findagram.co;
root /var/www/findagram;
location / { try_files $uri $uri/ /index.html; }
}
```
**Common Mistakes to AVOID:**
- Creating a FastAPI/Express backend just for findagram or findadispo
- Creating separate Docker images per domain when one would work
- Replacing icon names with sed without checking for text content collisions
- Using `npm ci` in Dockerfiles when package-lock.json doesn't exist (use `npm install`)

View File

@@ -0,0 +1,172 @@
-- Migration: Multi-domain user support with extended profile fields
-- Adds domain tracking for findagram.co and findadispo.com users
-- Adds extended profile fields (first_name, last_name, phone, sms_enabled)
-- Add new columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS first_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS last_name VARCHAR(100),
ADD COLUMN IF NOT EXISTS phone VARCHAR(20),
ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS domain VARCHAR(50) DEFAULT 'cannaiq.co';
-- Create index for domain-based queries
CREATE INDEX IF NOT EXISTS idx_users_domain ON users(domain);
-- Add domain column to wp_api_permissions
ALTER TABLE wp_api_permissions
ADD COLUMN IF NOT EXISTS domain VARCHAR(50) DEFAULT 'cannaiq.co';
-- Create index for domain-based permission queries
CREATE INDEX IF NOT EXISTS idx_wp_api_permissions_domain ON wp_api_permissions(domain);
-- Create findagram_users table for Find a Gram specific user data
CREATE TABLE IF NOT EXISTS findagram_users (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
-- Profile
display_name VARCHAR(100),
avatar_url TEXT,
bio TEXT,
-- Location preferences
preferred_city VARCHAR(100),
preferred_state VARCHAR(50),
location_lat DECIMAL(10, 8),
location_lng DECIMAL(11, 8),
-- Preferences
favorite_strains TEXT[], -- Array of strain types: hybrid, indica, sativa
favorite_categories TEXT[], -- flower, edibles, concentrates, etc.
price_alert_threshold DECIMAL(10, 2),
notifications_enabled BOOLEAN DEFAULT true,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
-- Create findadispo_users table for Find a Dispo specific user data
CREATE TABLE IF NOT EXISTS findadispo_users (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
-- Profile
display_name VARCHAR(100),
avatar_url TEXT,
-- Location preferences
preferred_city VARCHAR(100),
preferred_state VARCHAR(50),
location_lat DECIMAL(10, 8),
location_lng DECIMAL(11, 8),
search_radius_miles INTEGER DEFAULT 25,
-- Preferences
favorite_dispensary_ids INTEGER[], -- Array of dispensary IDs
deal_notifications BOOLEAN DEFAULT true,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id)
);
-- Create findagram_saved_searches table
CREATE TABLE IF NOT EXISTS findagram_saved_searches (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
-- Search criteria
query TEXT,
category VARCHAR(50),
brand VARCHAR(100),
strain_type VARCHAR(20),
min_price DECIMAL(10, 2),
max_price DECIMAL(10, 2),
min_thc DECIMAL(5, 2),
max_thc DECIMAL(5, 2),
city VARCHAR(100),
state VARCHAR(50),
-- Notification settings
notify_on_new BOOLEAN DEFAULT false,
notify_on_price_drop BOOLEAN DEFAULT false,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create findagram_favorites table
CREATE TABLE IF NOT EXISTS findagram_favorites (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
product_id INTEGER, -- References products table
dispensary_id INTEGER, -- References dispensaries table
-- Product snapshot at time of save
product_name VARCHAR(255),
product_brand VARCHAR(100),
product_price DECIMAL(10, 2),
product_image_url TEXT,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, product_id)
);
-- Create findagram_alerts table
CREATE TABLE IF NOT EXISTS findagram_alerts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
alert_type VARCHAR(50) NOT NULL, -- price_drop, back_in_stock, new_product
-- Target
product_id INTEGER,
brand VARCHAR(100),
category VARCHAR(50),
dispensary_id INTEGER,
-- Criteria
target_price DECIMAL(10, 2),
-- Status
is_active BOOLEAN DEFAULT true,
last_triggered_at TIMESTAMP,
trigger_count INTEGER DEFAULT 0,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for findagram tables
CREATE INDEX IF NOT EXISTS idx_findagram_users_user_id ON findagram_users(user_id);
CREATE INDEX IF NOT EXISTS idx_findadispo_users_user_id ON findadispo_users(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_saved_searches_user_id ON findagram_saved_searches(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_favorites_user_id ON findagram_favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_favorites_product_id ON findagram_favorites(product_id);
CREATE INDEX IF NOT EXISTS idx_findagram_alerts_user_id ON findagram_alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_findagram_alerts_active ON findagram_alerts(is_active) WHERE is_active = true;
-- Create view for admin user management across domains
CREATE OR REPLACE VIEW admin_users_view AS
SELECT
u.id,
u.email,
u.first_name,
u.last_name,
u.phone,
u.sms_enabled,
u.role,
u.domain,
u.created_at,
u.updated_at,
CASE
WHEN u.domain = 'findagram.co' THEN fg.display_name
WHEN u.domain = 'findadispo.com' THEN fd.display_name
ELSE NULL
END as display_name,
CASE
WHEN u.domain = 'findagram.co' THEN fg.preferred_city
WHEN u.domain = 'findadispo.com' THEN fd.preferred_city
ELSE NULL
END as preferred_city,
CASE
WHEN u.domain = 'findagram.co' THEN fg.preferred_state
WHEN u.domain = 'findadispo.com' THEN fd.preferred_state
ELSE NULL
END as preferred_state
FROM users u
LEFT JOIN findagram_users fg ON u.id = fg.user_id AND u.domain = 'findagram.co'
LEFT JOIN findadispo_users fd ON u.id = fd.user_id AND u.domain = 'findadispo.com';
-- Update existing cannaiq users to have domain set
UPDATE users SET domain = 'cannaiq.co' WHERE domain IS NULL;

View File

@@ -0,0 +1,47 @@
-- Migration 036: Add fields for findadispo.com frontend
-- These fields are needed for the consumer-facing dispensary locator UI
-- This migration is idempotent - safe to run multiple times
-- Add hours as JSONB to support structured hours data
-- Example: {"monday": {"open": "09:00", "close": "21:00"}, "tuesday": {...}, ...}
-- Or simple: {"formatted": "Mon-Sat 9am-9pm, Sun 10am-6pm"}
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'hours') THEN
ALTER TABLE dispensaries ADD COLUMN hours JSONB DEFAULT NULL;
END IF;
END $$;
-- Add amenities as TEXT array
-- Example: ['Wheelchair Accessible', 'ATM', 'Online Ordering', 'Curbside Pickup', 'Delivery']
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'amenities') THEN
ALTER TABLE dispensaries ADD COLUMN amenities TEXT[] DEFAULT '{}';
END IF;
END $$;
-- Add description for the "About" section on dispensary detail pages
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'description') THEN
ALTER TABLE dispensaries ADD COLUMN description TEXT DEFAULT NULL;
END IF;
END $$;
-- Add image_url for the main dispensary hero image
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'dispensaries' AND column_name = 'image_url') THEN
ALTER TABLE dispensaries ADD COLUMN image_url TEXT DEFAULT NULL;
END IF;
END $$;
-- Add indexes for filtering (IF NOT EXISTS is supported for indexes in PG 9.5+)
CREATE INDEX IF NOT EXISTS idx_dispensaries_amenities ON dispensaries USING GIN (amenities);
-- Add comments for documentation (COMMENT is idempotent - re-running just updates the comment)
COMMENT ON COLUMN dispensaries.hours IS 'Store hours in JSONB format. Can be structured by day or formatted string.';
COMMENT ON COLUMN dispensaries.amenities IS 'Array of amenity tags like Wheelchair Accessible, ATM, Online Ordering, etc.';
COMMENT ON COLUMN dispensaries.description IS 'Description text for dispensary detail page About section.';
COMMENT ON COLUMN dispensaries.image_url IS 'URL to main dispensary image for hero/card display.';

View File

@@ -1,6 +1,6 @@
{
"name": "dutchie-menus-backend",
"version": "1.0.0",
"version": "1.5.1",
"description": "Backend API for Dutchie Menus scraper and management",
"main": "dist/index.js",
"scripts": {

View File

@@ -657,6 +657,193 @@ router.get('/products/:id/snapshots', async (req: Request, res: Response) => {
}
});
/**
* GET /api/dutchie-az/products/:id/similar
* Get similar products (same brand + category), limited to 4
* Returns products with lowest prices first
*/
router.get('/products/:id/similar', async (req: Request, res: Response) => {
try {
const { id } = req.params;
// Use the exact SQL query provided
const { rows } = await query<{
product_id: number;
name: string;
brand_name: string;
image_url: string;
rec_min_price_cents: number;
}>(
`
WITH base AS (
SELECT id AS base_product_id, brand_name, category
FROM dutchie_products WHERE id = $1
),
latest_prices AS (
SELECT DISTINCT ON (dps.dutchie_product_id)
dps.dutchie_product_id, dps.rec_min_price_cents
FROM dutchie_product_snapshots dps
ORDER BY dps.dutchie_product_id, dps.crawled_at DESC
)
SELECT p.id AS product_id, p.name, p.brand_name, p.primary_image_url as image_url, lp.rec_min_price_cents
FROM dutchie_products p
JOIN base b ON p.category = b.category AND p.brand_name = b.brand_name
JOIN latest_prices lp ON lp.dutchie_product_id = p.id
WHERE p.id <> b.base_product_id AND lp.rec_min_price_cents IS NOT NULL
ORDER BY lp.rec_min_price_cents ASC
LIMIT 4
`,
[id]
);
// Transform to the expected response format
const similarProducts = rows.map((row) => ({
productId: row.product_id,
name: row.name,
brandName: row.brand_name,
imageUrl: row.image_url,
price: row.rec_min_price_cents ? row.rec_min_price_cents / 100 : null,
}));
res.json({ similarProducts });
} catch (error: any) {
console.error('Error fetching similar products:', error);
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/dutchie-az/products/:id/availability
* Get dispensaries that carry this product, with distance from user location
* Query params:
* - lat: User latitude (required)
* - lng: User longitude (required)
* - max_radius_miles: Maximum search radius in miles (optional, default 50)
*/
router.get('/products/:id/availability', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { lat, lng, max_radius_miles = '50' } = req.query;
// Validate required params
if (!lat || !lng) {
return res.status(400).json({ error: 'lat and lng query parameters are required' });
}
const userLat = parseFloat(lat as string);
const userLng = parseFloat(lng as string);
const maxRadius = parseFloat(max_radius_miles as string);
if (isNaN(userLat) || isNaN(userLng)) {
return res.status(400).json({ error: 'lat and lng must be valid numbers' });
}
// First get the product to find its external_product_id
const { rows: productRows } = await query(
`SELECT external_product_id, name, brand_name FROM dutchie_products WHERE id = $1`,
[id]
);
if (productRows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
const externalProductId = productRows[0].external_product_id;
// Find all dispensaries carrying this product (by external_product_id match)
// with distance calculation using Haversine formula
const { rows: offers } = await query<{
dispensary_id: number;
dispensary_name: string;
city: string;
state: string;
address: string;
latitude: number;
longitude: number;
menu_url: string;
stock_status: string;
rec_min_price_cents: number;
distance_miles: number;
}>(
`
WITH latest_snapshots AS (
SELECT DISTINCT ON (s.dutchie_product_id)
s.dutchie_product_id,
s.dispensary_id,
s.stock_status,
s.rec_min_price_cents,
s.crawled_at
FROM dutchie_product_snapshots s
JOIN dutchie_products p ON s.dutchie_product_id = p.id
WHERE p.external_product_id = $1
ORDER BY s.dutchie_product_id, s.crawled_at DESC
)
SELECT
d.id as dispensary_id,
COALESCE(d.dba_name, d.name) as dispensary_name,
d.city,
d.state,
d.address,
d.latitude,
d.longitude,
d.menu_url,
ls.stock_status,
ls.rec_min_price_cents,
-- Haversine distance formula (in miles)
(3959 * acos(
cos(radians($2)) * cos(radians(d.latitude)) *
cos(radians(d.longitude) - radians($3)) +
sin(radians($2)) * sin(radians(d.latitude))
)) as distance_miles
FROM latest_snapshots ls
JOIN dispensaries d ON ls.dispensary_id = d.id
WHERE d.latitude IS NOT NULL
AND d.longitude IS NOT NULL
HAVING (3959 * acos(
cos(radians($2)) * cos(radians(d.latitude)) *
cos(radians(d.longitude) - radians($3)) +
sin(radians($2)) * sin(radians(d.latitude))
)) <= $4
ORDER BY distance_miles ASC
`,
[externalProductId, userLat, userLng, maxRadius]
);
// Find the best (lowest) price for isBestPrice flag
const validPrices = offers
.filter(o => o.rec_min_price_cents && o.rec_min_price_cents > 0)
.map(o => o.rec_min_price_cents);
const bestPrice = validPrices.length > 0 ? Math.min(...validPrices) : null;
// Transform for frontend
const availability = offers.map(o => ({
dispensaryId: o.dispensary_id,
dispensaryName: o.dispensary_name,
city: o.city,
state: o.state,
address: o.address,
latitude: o.latitude,
longitude: o.longitude,
menuUrl: o.menu_url,
stockStatus: o.stock_status || 'unknown',
price: o.rec_min_price_cents ? o.rec_min_price_cents / 100 : null,
distanceMiles: Math.round(o.distance_miles * 10) / 10, // Round to 1 decimal
isBestPrice: bestPrice !== null && o.rec_min_price_cents === bestPrice,
}));
res.json({
productId: parseInt(id, 10),
productName: productRows[0].name,
brandName: productRows[0].brand_name,
totalCount: availability.length,
offers: availability,
});
} catch (error: any) {
console.error('Error fetching product availability:', error);
res.status(500).json({ error: error.message });
}
});
// ============================================================
// CATEGORIES
// ============================================================

View File

@@ -64,8 +64,13 @@ import { dutchieAZRouter, startScheduler as startDutchieAZScheduler, initializeD
import { trackApiUsage, checkRateLimit } from './middleware/apiTokenTracker';
import { startCrawlScheduler } from './services/crawl-scheduler';
import { validateWordPressPermissions } from './middleware/wordpressPermissions';
import { markTrustedDomains } from './middleware/trustedDomains';
// Apply WordPress permissions validation first (sets req.apiToken)
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
// These domains can access the API without authentication
app.use(markTrustedDomains);
// Apply WordPress permissions validation (sets req.apiToken for API key requests)
app.use(validateWordPressPermissions);
// Apply API tracking middleware globally

View File

@@ -0,0 +1,107 @@
import { Request, Response, NextFunction } from 'express';
/**
* List of trusted domains that can access the API without authentication.
* These are our own frontends that should have unrestricted access.
*/
const TRUSTED_DOMAINS = [
'cannaiq.co',
'www.cannaiq.co',
'findagram.co',
'www.findagram.co',
'findadispo.com',
'www.findadispo.com',
// Development domains
'localhost',
'127.0.0.1',
];
export interface TrustedDomainRequest extends Request {
isTrustedDomain?: boolean;
}
/**
* Extracts domain from Origin or Referer header
*/
function extractDomain(header: string): string | null {
try {
const url = new URL(header);
return url.hostname;
} catch {
return null;
}
}
/**
* Checks if the request comes from a trusted domain
*/
function isRequestFromTrustedDomain(req: Request): boolean {
const origin = req.get('origin');
const referer = req.get('referer');
// Check Origin header first (preferred for CORS requests)
if (origin) {
const domain = extractDomain(origin);
if (domain && TRUSTED_DOMAINS.includes(domain)) {
return true;
}
}
// Fallback to Referer header
if (referer) {
const domain = extractDomain(referer);
if (domain && TRUSTED_DOMAINS.includes(domain)) {
return true;
}
}
return false;
}
/**
* Middleware that marks requests from trusted domains.
* This allows the auth middleware to skip authentication for these requests.
*
* Trusted domains: cannaiq.co, findagram.co, findadispo.com
*
* Usage: Apply this middleware BEFORE the auth middleware
*/
export function markTrustedDomains(
req: TrustedDomainRequest,
res: Response,
next: NextFunction
) {
req.isTrustedDomain = isRequestFromTrustedDomain(req);
next();
}
/**
* Middleware that allows requests from trusted domains to bypass auth.
* Other requests must have a valid API key in x-api-key header.
*
* This replaces the standard auth middleware for public API endpoints.
*/
export function requireApiKeyOrTrustedDomain(
req: TrustedDomainRequest,
res: Response,
next: NextFunction
) {
// Allow trusted domains without authentication
if (req.isTrustedDomain) {
return next();
}
// Check for API key
const apiKey = req.headers['x-api-key'] as string;
if (!apiKey) {
return res.status(401).json({
error: 'Unauthorized',
message: 'API key required. Provide x-api-key header or access from a trusted domain.'
});
}
// If API key is provided, let the WordPress permissions middleware handle validation
// The WordPress middleware should have already validated the key if present
next();
}

View File

@@ -840,6 +840,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
d.longitude,
d.menu_type as platform,
d.menu_url,
d.hours,
d.amenities,
d.description,
d.image_url,
d.google_rating,
d.google_review_count,
COALESCE(pc.product_count, 0) as product_count,
COALESCE(pc.in_stock_count, 0) as in_stock_count,
pc.last_updated
@@ -885,6 +891,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
longitude: parseFloat(d.longitude)
} : null,
platform: d.platform,
hours: d.hours || null,
amenities: d.amenities || [],
description: d.description || null,
image_url: d.image_url || null,
rating: d.google_rating ? parseFloat(d.google_rating) : null,
review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null,
product_count: parseInt(d.product_count || '0', 10),
in_stock_count: parseInt(d.in_stock_count || '0', 10),
last_updated: d.last_updated,
@@ -935,6 +947,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
d.longitude,
d.menu_type as platform,
d.menu_url,
d.hours,
d.amenities,
d.description,
d.image_url,
d.google_rating,
d.google_review_count,
COALESCE(pc.product_count, 0) as product_count,
COALESCE(pc.in_stock_count, 0) as in_stock_count,
pc.last_updated
@@ -980,6 +998,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
longitude: parseFloat(d.longitude)
} : null,
platform: d.platform,
hours: d.hours || null,
amenities: d.amenities || [],
description: d.description || null,
image_url: d.image_url || null,
rating: d.google_rating ? parseFloat(d.google_rating) : null,
review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null,
product_count: parseInt(d.product_count || '0', 10),
in_stock_count: parseInt(d.in_stock_count || '0', 10),
last_updated: d.last_updated,

View File

@@ -9,14 +9,36 @@ const router = Router();
router.use(authMiddleware);
router.use(requireRole('admin', 'superadmin'));
// Get all users
// Get all users with search and filter
router.get('/', async (req: AuthRequest, res) => {
try {
const result = await pool.query(`
SELECT id, email, role, created_at, updated_at
const { search, domain } = req.query;
let query = `
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
FROM users
ORDER BY created_at DESC
`);
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
// Search by email, first_name, or last_name
if (search && typeof search === 'string') {
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
// Filter by domain
if (domain && typeof domain === 'string') {
query += ` AND domain = $${paramIndex}`;
params.push(domain);
paramIndex++;
}
query += ` ORDER BY created_at DESC`;
const result = await pool.query(query, params);
res.json({ users: result.rows });
} catch (error) {
console.error('Error fetching users:', error);
@@ -29,7 +51,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
const result = await pool.query(`
SELECT id, email, role, created_at, updated_at
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
FROM users
WHERE id = $1
`, [id]);
@@ -48,7 +70,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
// Create user
router.post('/', async (req: AuthRequest, res) => {
try {
const { email, password, role } = req.body;
const { email, password, role, first_name, last_name, phone, domain } = req.body;
if (!email || !password) {
return res.status(400).json({ error: 'Email and password are required' });
@@ -60,6 +82,12 @@ router.post('/', async (req: AuthRequest, res) => {
return res.status(400).json({ error: 'Invalid role. Must be: admin, analyst, or viewer' });
}
// Check for valid domain
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
if (domain && !validDomains.includes(domain)) {
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
}
// Check if email already exists
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
if (existing.rows.length > 0) {
@@ -70,10 +98,10 @@ router.post('/', async (req: AuthRequest, res) => {
const passwordHash = await bcrypt.hash(password, 10);
const result = await pool.query(`
INSERT INTO users (email, password_hash, role)
VALUES ($1, $2, $3)
RETURNING id, email, role, created_at, updated_at
`, [email, passwordHash, role || 'viewer']);
INSERT INTO users (email, password_hash, role, first_name, last_name, phone, domain)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
`, [email, passwordHash, role || 'viewer', first_name || null, last_name || null, phone || null, domain || 'cannaiq.co']);
res.status(201).json({ user: result.rows[0] });
} catch (error) {
@@ -86,7 +114,7 @@ router.post('/', async (req: AuthRequest, res) => {
router.put('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
const { email, password, role } = req.body;
const { email, password, role, first_name, last_name, phone, domain } = req.body;
// Check if user exists
const existing = await pool.query('SELECT id FROM users WHERE id = $1', [id]);
@@ -100,6 +128,12 @@ router.put('/:id', async (req: AuthRequest, res) => {
return res.status(400).json({ error: 'Invalid role' });
}
// Check for valid domain
const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com'];
if (domain && !validDomains.includes(domain)) {
return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' });
}
// Prevent non-superadmin from modifying superadmin users
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
@@ -132,6 +166,27 @@ router.put('/:id', async (req: AuthRequest, res) => {
values.push(role);
}
// Handle profile fields (allow setting to null with explicit undefined check)
if (first_name !== undefined) {
updates.push(`first_name = $${paramIndex++}`);
values.push(first_name || null);
}
if (last_name !== undefined) {
updates.push(`last_name = $${paramIndex++}`);
values.push(last_name || null);
}
if (phone !== undefined) {
updates.push(`phone = $${paramIndex++}`);
values.push(phone || null);
}
if (domain !== undefined) {
updates.push(`domain = $${paramIndex++}`);
values.push(domain);
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
@@ -143,7 +198,7 @@ router.put('/:id', async (req: AuthRequest, res) => {
UPDATE users
SET ${updates.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, email, role, created_at, updated_at
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
`, values);
res.json({ user: result.rows[0] });

View File

@@ -0,0 +1,18 @@
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/findadispo
# JWT Settings
SECRET_KEY=your-super-secret-key-change-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# External API
DISPENSARY_API_URL=http://localhost:3010
DISPENSARY_API_KEY=your-api-key
# CORS
FRONTEND_URL=http://localhost:3000
# Server
HOST=0.0.0.0
PORT=8000

139
findadispo/backend/auth.py Normal file
View File

@@ -0,0 +1,139 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from config import get_settings
from database import get_db
from models import User
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
# Pydantic models
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: str
phone: Optional[str] = None
default_location: Optional[str] = None
class UserUpdate(BaseModel):
full_name: Optional[str] = None
phone: Optional[str] = None
default_location: Optional[str] = None
notify_price_alerts: Optional[bool] = None
notify_new_dispensaries: Optional[bool] = None
notify_weekly_digest: Optional[bool] = None
notify_promotions: Optional[bool] = None
class UserResponse(BaseModel):
id: int
email: str
full_name: Optional[str]
phone: Optional[str]
default_location: Optional[str]
is_active: bool
is_verified: bool
notify_price_alerts: bool
notify_new_dispensaries: bool
notify_weekly_digest: bool
notify_promotions: bool
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
email: Optional[str] = None
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def get_user_by_email(db: Session, email: str) -> Optional[User]:
return db.query(User).filter(User.email == email).first()
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
user = get_user_by_email(db, email)
if not user:
return None
if not verify_password(password, user.hashed_password):
return None
return user
def create_user(db: Session, user_data: UserCreate) -> User:
hashed_password = get_password_hash(user_data.password)
db_user = User(
email=user_data.email,
hashed_password=hashed_password,
full_name=user_data.full_name,
phone=user_data.phone,
default_location=user_data.default_location,
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
token_data = TokenData(email=email)
except JWTError:
raise credentials_exception
user = get_user_by_email(db, email=token_data.email)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

View File

@@ -0,0 +1,31 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Database
database_url: str = "postgresql://user:password@localhost:5432/findadispo"
# JWT Settings
secret_key: str = "your-super-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
# External API
dispensary_api_url: str = "http://localhost:3010"
dispensary_api_key: str = ""
# CORS
frontend_url: str = "http://localhost:3000"
# Server
host: str = "0.0.0.0"
port: int = 8000
class Config:
env_file = ".env"
@lru_cache()
def get_settings():
return Settings()

View File

@@ -0,0 +1,25 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from config import get_settings
settings = get_settings()
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency to get database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
"""Initialize database tables"""
Base.metadata.create_all(bind=engine)

View File

@@ -0,0 +1,77 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(255))
phone = Column(String(50))
default_location = Column(String(255))
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Notification preferences
notify_price_alerts = Column(Boolean, default=True)
notify_new_dispensaries = Column(Boolean, default=False)
notify_weekly_digest = Column(Boolean, default=True)
notify_promotions = Column(Boolean, default=False)
# Relationships
saved_searches = relationship("SavedSearch", back_populates="user", cascade="all, delete-orphan")
alerts = relationship("PriceAlert", back_populates="user", cascade="all, delete-orphan")
class SavedSearch(Base):
__tablename__ = "saved_searches"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
name = Column(String(255), nullable=False)
query = Column(String(255))
filters = Column(JSON) # Store filters as JSON
results_count = Column(Integer, default=0)
last_used = Column(DateTime(timezone=True), server_default=func.now())
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationship
user = relationship("User", back_populates="saved_searches")
class PriceAlert(Base):
__tablename__ = "price_alerts"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
product_name = Column(String(255), nullable=False)
dispensary_id = Column(Integer)
dispensary_name = Column(String(255))
target_price = Column(Float, nullable=False)
current_price = Column(Float)
is_active = Column(Boolean, default=True)
is_triggered = Column(Boolean, default=False)
triggered_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationship
user = relationship("User", back_populates="alerts")
class ContactMessage(Base):
__tablename__ = "contact_messages"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
email = Column(String(255), nullable=False)
subject = Column(String(255))
message = Column(Text, nullable=False)
is_read = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,11 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy==2.0.25
psycopg2-binary==2.9.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
pydantic[email]==2.5.3
pydantic-settings==2.1.0
httpx==0.26.0
alembic==1.13.1

View File

@@ -0,0 +1 @@
# Routes package

View File

@@ -0,0 +1,234 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from database import get_db
from auth import get_current_active_user
from models import User, PriceAlert
router = APIRouter(prefix="/alerts", tags=["Price Alerts"])
class AlertCreate(BaseModel):
product_name: str
dispensary_id: Optional[int] = None
dispensary_name: Optional[str] = None
target_price: float
current_price: Optional[float] = None
class AlertUpdate(BaseModel):
target_price: Optional[float] = None
is_active: Optional[bool] = None
class AlertResponse(BaseModel):
id: int
product_name: str
dispensary_id: Optional[int]
dispensary_name: Optional[str]
target_price: float
current_price: Optional[float]
is_active: bool
is_triggered: bool
created_at: str
class Config:
from_attributes = True
@router.get("/", response_model=List[AlertResponse])
def get_alerts(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get all price alerts for the current user"""
alerts = db.query(PriceAlert).filter(
PriceAlert.user_id == current_user.id
).order_by(PriceAlert.created_at.desc()).all()
return [
AlertResponse(
id=a.id,
product_name=a.product_name,
dispensary_id=a.dispensary_id,
dispensary_name=a.dispensary_name,
target_price=a.target_price,
current_price=a.current_price,
is_active=a.is_active,
is_triggered=a.is_triggered,
created_at=a.created_at.isoformat() if a.created_at else ""
)
for a in alerts
]
@router.post("/", response_model=AlertResponse, status_code=status.HTTP_201_CREATED)
def create_alert(
alert_data: AlertCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Create a new price alert"""
alert = PriceAlert(
user_id=current_user.id,
product_name=alert_data.product_name,
dispensary_id=alert_data.dispensary_id,
dispensary_name=alert_data.dispensary_name,
target_price=alert_data.target_price,
current_price=alert_data.current_price,
is_triggered=alert_data.current_price and alert_data.current_price <= alert_data.target_price
)
db.add(alert)
db.commit()
db.refresh(alert)
return AlertResponse(
id=alert.id,
product_name=alert.product_name,
dispensary_id=alert.dispensary_id,
dispensary_name=alert.dispensary_name,
target_price=alert.target_price,
current_price=alert.current_price,
is_active=alert.is_active,
is_triggered=alert.is_triggered,
created_at=alert.created_at.isoformat() if alert.created_at else ""
)
@router.get("/{alert_id}", response_model=AlertResponse)
def get_alert(
alert_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get a specific price alert"""
alert = db.query(PriceAlert).filter(
PriceAlert.id == alert_id,
PriceAlert.user_id == current_user.id
).first()
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Alert not found"
)
return AlertResponse(
id=alert.id,
product_name=alert.product_name,
dispensary_id=alert.dispensary_id,
dispensary_name=alert.dispensary_name,
target_price=alert.target_price,
current_price=alert.current_price,
is_active=alert.is_active,
is_triggered=alert.is_triggered,
created_at=alert.created_at.isoformat() if alert.created_at else ""
)
@router.put("/{alert_id}", response_model=AlertResponse)
def update_alert(
alert_id: int,
alert_update: AlertUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Update a price alert"""
alert = db.query(PriceAlert).filter(
PriceAlert.id == alert_id,
PriceAlert.user_id == current_user.id
).first()
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Alert not found"
)
update_data = alert_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(alert, field, value)
# Check if alert should be triggered
if alert.current_price and alert.current_price <= alert.target_price:
alert.is_triggered = True
db.commit()
db.refresh(alert)
return AlertResponse(
id=alert.id,
product_name=alert.product_name,
dispensary_id=alert.dispensary_id,
dispensary_name=alert.dispensary_name,
target_price=alert.target_price,
current_price=alert.current_price,
is_active=alert.is_active,
is_triggered=alert.is_triggered,
created_at=alert.created_at.isoformat() if alert.created_at else ""
)
@router.patch("/{alert_id}/toggle")
def toggle_alert(
alert_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Toggle alert active status"""
alert = db.query(PriceAlert).filter(
PriceAlert.id == alert_id,
PriceAlert.user_id == current_user.id
).first()
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Alert not found"
)
alert.is_active = not alert.is_active
db.commit()
return {"message": f"Alert {'activated' if alert.is_active else 'deactivated'}"}
@router.delete("/{alert_id}")
def delete_alert(
alert_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Delete a price alert"""
alert = db.query(PriceAlert).filter(
PriceAlert.id == alert_id,
PriceAlert.user_id == current_user.id
).first()
if not alert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Alert not found"
)
db.delete(alert)
db.commit()
return {"message": "Alert deleted"}
@router.get("/stats/summary")
def get_alert_stats(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get alert statistics for dashboard"""
alerts = db.query(PriceAlert).filter(
PriceAlert.user_id == current_user.id
).all()
return {
"total": len(alerts),
"active": len([a for a in alerts if a.is_active]),
"triggered": len([a for a in alerts if a.is_triggered]),
}

View File

@@ -0,0 +1,108 @@
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from database import get_db
from config import get_settings
from auth import (
UserCreate, UserUpdate, UserResponse, Token,
authenticate_user, create_user, create_access_token,
get_user_by_email, get_current_active_user, get_password_hash
)
from models import User
router = APIRouter(prefix="/auth", tags=["Authentication"])
settings = get_settings()
@router.post("/signup", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
def signup(user_data: UserCreate, db: Session = Depends(get_db)):
"""Register a new user"""
# Check if user exists
existing_user = get_user_by_email(db, user_data.email)
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create user
user = create_user(db, user_data)
return user
@router.post("/login", response_model=Token)
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""Login and get access token"""
user = authenticate_user(db, form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.email}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
def get_current_user_info(current_user: User = Depends(get_current_active_user)):
"""Get current user information"""
return current_user
@router.put("/me", response_model=UserResponse)
def update_current_user(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Update current user information"""
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
db.commit()
db.refresh(current_user)
return current_user
@router.post("/change-password")
def change_password(
current_password: str,
new_password: str,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Change user password"""
from auth import verify_password
if not verify_password(current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Incorrect current password"
)
current_user.hashed_password = get_password_hash(new_password)
db.commit()
return {"message": "Password updated successfully"}
@router.delete("/me")
def delete_account(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Delete user account"""
db.delete(current_user)
db.commit()
return {"message": "Account deleted successfully"}

View File

@@ -0,0 +1,49 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from database import get_db
from models import ContactMessage
router = APIRouter(prefix="/contact", tags=["Contact"])
class ContactCreate(BaseModel):
name: str
email: EmailStr
subject: str
message: str
class ContactResponse(BaseModel):
id: int
name: str
email: str
subject: str
message: str
created_at: str
class Config:
from_attributes = True
@router.post("/", status_code=status.HTTP_201_CREATED)
def submit_contact(
contact_data: ContactCreate,
db: Session = Depends(get_db)
):
"""Submit a contact form message"""
message = ContactMessage(
name=contact_data.name,
email=contact_data.email,
subject=contact_data.subject,
message=contact_data.message
)
db.add(message)
db.commit()
db.refresh(message)
return {
"message": "Thank you for your message! We'll get back to you soon.",
"id": message.id
}

View File

@@ -0,0 +1,164 @@
from typing import Optional
from fastapi import APIRouter, HTTPException
import httpx
from pydantic import BaseModel
from config import get_settings
router = APIRouter(prefix="/dispensaries", tags=["Dispensaries"])
settings = get_settings()
class DispensaryQuery(BaseModel):
lat: Optional[float] = None
lng: Optional[float] = None
city: Optional[str] = None
state: Optional[str] = None
radius: Optional[int] = 25
limit: Optional[int] = 20
offset: Optional[int] = 0
async def fetch_from_api(endpoint: str, params: dict = None):
"""Fetch data from the external dispensary API"""
headers = {}
if settings.dispensary_api_key:
headers["X-API-Key"] = settings.dispensary_api_key
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{settings.dispensary_api_url}{endpoint}",
params=params,
headers=headers,
timeout=30.0
)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"API error: {e.response.text}"
)
except httpx.RequestError as e:
raise HTTPException(
status_code=503,
detail=f"Failed to connect to dispensary API: {str(e)}"
)
@router.get("/")
async def search_dispensaries(
lat: Optional[float] = None,
lng: Optional[float] = None,
city: Optional[str] = None,
state: Optional[str] = "AZ",
radius: int = 25,
limit: int = 20,
offset: int = 0,
open_now: bool = False,
min_rating: Optional[float] = None
):
"""Search for dispensaries by location"""
params = {
"limit": limit,
"offset": offset,
"state": state
}
if lat and lng:
params["lat"] = lat
params["lng"] = lng
params["radius"] = radius
if city:
params["city"] = city
# Fetch from external API
data = await fetch_from_api("/api/az/stores", params)
# Apply client-side filters if needed
stores = data.get("stores", [])
if open_now:
# Filter stores that are currently open
# This would need actual business hours logic
pass
if min_rating:
stores = [s for s in stores if (s.get("rating") or 0) >= min_rating]
return {
"dispensaries": stores,
"total": len(stores),
"limit": limit,
"offset": offset
}
@router.get("/{dispensary_id}")
async def get_dispensary(dispensary_id: int):
"""Get details for a specific dispensary"""
data = await fetch_from_api(f"/api/az/stores/{dispensary_id}")
return data
@router.get("/{dispensary_id}/products")
async def get_dispensary_products(
dispensary_id: int,
category: Optional[str] = None,
search: Optional[str] = None,
limit: int = 50,
offset: int = 0
):
"""Get products for a specific dispensary"""
params = {
"limit": limit,
"offset": offset
}
if category:
params["category"] = category
if search:
params["search"] = search
data = await fetch_from_api(f"/api/az/stores/{dispensary_id}/products", params)
return data
@router.get("/{dispensary_id}/categories")
async def get_dispensary_categories(dispensary_id: int):
"""Get product categories for a dispensary"""
data = await fetch_from_api(f"/api/az/stores/{dispensary_id}/categories")
return data
@router.get("/nearby")
async def get_nearby_dispensaries(
lat: float,
lng: float,
radius: int = 10,
limit: int = 10
):
"""Get nearby dispensaries by coordinates"""
params = {
"lat": lat,
"lng": lng,
"radius": radius,
"limit": limit
}
data = await fetch_from_api("/api/az/stores", params)
return data.get("stores", [])
@router.get("/featured")
async def get_featured_dispensaries(limit: int = 6):
"""Get featured dispensaries for the homepage"""
# For now, return top-rated dispensaries
params = {
"limit": limit,
"sort": "rating"
}
data = await fetch_from_api("/api/az/stores", params)
return data.get("stores", [])

View File

@@ -0,0 +1,201 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.sql import func
from pydantic import BaseModel
from database import get_db
from auth import get_current_active_user
from models import User, SavedSearch
router = APIRouter(prefix="/searches", tags=["Saved Searches"])
class SavedSearchCreate(BaseModel):
name: str
query: str
filters: Optional[dict] = None
results_count: Optional[int] = 0
class SavedSearchUpdate(BaseModel):
name: Optional[str] = None
filters: Optional[dict] = None
results_count: Optional[int] = None
class SavedSearchResponse(BaseModel):
id: int
name: str
query: str
filters: Optional[dict]
results_count: int
last_used: str
class Config:
from_attributes = True
@router.get("/", response_model=List[SavedSearchResponse])
def get_saved_searches(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get all saved searches for the current user"""
searches = db.query(SavedSearch).filter(
SavedSearch.user_id == current_user.id
).order_by(SavedSearch.last_used.desc()).all()
return [
SavedSearchResponse(
id=s.id,
name=s.name,
query=s.query,
filters=s.filters,
results_count=s.results_count,
last_used=s.last_used.isoformat() if s.last_used else ""
)
for s in searches
]
@router.post("/", response_model=SavedSearchResponse, status_code=status.HTTP_201_CREATED)
def create_saved_search(
search_data: SavedSearchCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Create a new saved search"""
search = SavedSearch(
user_id=current_user.id,
name=search_data.name,
query=search_data.query,
filters=search_data.filters,
results_count=search_data.results_count
)
db.add(search)
db.commit()
db.refresh(search)
return SavedSearchResponse(
id=search.id,
name=search.name,
query=search.query,
filters=search.filters,
results_count=search.results_count,
last_used=search.last_used.isoformat() if search.last_used else ""
)
@router.get("/{search_id}", response_model=SavedSearchResponse)
def get_saved_search(
search_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get a specific saved search"""
search = db.query(SavedSearch).filter(
SavedSearch.id == search_id,
SavedSearch.user_id == current_user.id
).first()
if not search:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Saved search not found"
)
return SavedSearchResponse(
id=search.id,
name=search.name,
query=search.query,
filters=search.filters,
results_count=search.results_count,
last_used=search.last_used.isoformat() if search.last_used else ""
)
@router.put("/{search_id}", response_model=SavedSearchResponse)
def update_saved_search(
search_id: int,
search_update: SavedSearchUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Update a saved search"""
search = db.query(SavedSearch).filter(
SavedSearch.id == search_id,
SavedSearch.user_id == current_user.id
).first()
if not search:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Saved search not found"
)
update_data = search_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(search, field, value)
db.commit()
db.refresh(search)
return SavedSearchResponse(
id=search.id,
name=search.name,
query=search.query,
filters=search.filters,
results_count=search.results_count,
last_used=search.last_used.isoformat() if search.last_used else ""
)
@router.post("/{search_id}/use")
def mark_search_used(
search_id: int,
results_count: Optional[int] = None,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Mark a saved search as used (updates last_used timestamp)"""
search = db.query(SavedSearch).filter(
SavedSearch.id == search_id,
SavedSearch.user_id == current_user.id
).first()
if not search:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Saved search not found"
)
search.last_used = func.now()
if results_count is not None:
search.results_count = results_count
db.commit()
return {"message": "Search marked as used"}
@router.delete("/{search_id}")
def delete_saved_search(
search_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Delete a saved search"""
search = db.query(SavedSearch).filter(
SavedSearch.id == search_id,
SavedSearch.user_id == current_user.id
).first()
if not search:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Saved search not found"
)
db.delete(search)
db.commit()
return {"message": "Saved search deleted"}

View File

@@ -0,0 +1,81 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from config import get_settings
from database import init_db
from routes.auth_routes import router as auth_router
from routes.search_routes import router as search_router
from routes.alerts_routes import router as alerts_router
from routes.dispensary_routes import router as dispensary_router
from routes.contact_routes import router as contact_router
settings = get_settings()
# Create FastAPI app
app = FastAPI(
title="Find a Dispensary API",
description="Backend API for Find a Dispensary - Cannabis dispensary locator",
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc"
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=[
settings.frontend_url,
"http://localhost:3000",
"http://localhost:5173",
"https://findadispo.com",
"https://www.findadispo.com"
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(auth_router, prefix="/api")
app.include_router(search_router, prefix="/api")
app.include_router(alerts_router, prefix="/api")
app.include_router(dispensary_router, prefix="/api")
app.include_router(contact_router, prefix="/api")
@app.on_event("startup")
async def startup_event():
"""Initialize database on startup"""
init_db()
@app.get("/")
def root():
"""Root endpoint"""
return {
"name": "Find a Dispensary API",
"version": "1.0.0",
"status": "running"
}
@app.get("/health")
def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
@app.get("/api/version")
def api_version():
"""API version endpoint"""
return {"version": "1.0.0"}
if __name__ == "__main__":
uvicorn.run(
"server:app",
host=settings.host,
port=settings.port,
reload=True
)

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")],
}

View File

@@ -0,0 +1,9 @@
__pycache__
*.pyc
*.pyo
.env
.env.local
.venv
venv
*.log
.DS_Store

View File

@@ -0,0 +1,13 @@
# Find a Gram Backend Environment Variables
# Application
DEBUG=false
# Database
DATABASE_URL=postgresql+asyncpg://findagram:findagram_pass@localhost:5432/findagram
# JWT Secret (generate with: openssl rand -hex 32)
SECRET_KEY=your-super-secret-key-change-in-production
# CORS Origins (comma-separated)
CORS_ORIGINS=["http://localhost:3001","http://localhost:3000"]

View File

@@ -0,0 +1,25 @@
# Find a Gram Backend - FastAPI
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Expose port
EXPOSE 8001
# Run with uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8001"]

View File

@@ -0,0 +1 @@
# Find a Gram Backend

View File

@@ -0,0 +1,29 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Application
app_name: str = "Find a Gram API"
app_version: str = "1.0.0"
debug: bool = False
# Database
database_url: str = "postgresql+asyncpg://findagram:findagram_pass@localhost:5432/findagram"
# JWT
secret_key: str = "your-super-secret-key-change-in-production"
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# CORS
cors_origins: list[str] = ["http://localhost:3001", "http://localhost:3000"]
class Config:
env_file = ".env"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
return Settings()

View File

@@ -0,0 +1,30 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()

View File

@@ -0,0 +1,4 @@
from app.models.user import User
from app.models.product import Product, Dispensary, ProductPrice, Category, Brand
__all__ = ["User", "Product", "Dispensary", "ProductPrice", "Category", "Brand"]

View File

@@ -0,0 +1,120 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Category(Base):
__tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
slug = Column(String(100), unique=True, nullable=False)
description = Column(Text)
icon = Column(String(50))
image_url = Column(Text)
product_count = Column(Integer, default=0)
products = relationship("Product", back_populates="category")
class Brand(Base):
__tablename__ = "brands"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), unique=True, nullable=False)
slug = Column(String(255), unique=True, nullable=False)
description = Column(Text)
logo_url = Column(Text)
website = Column(String(500))
product_count = Column(Integer, default=0)
products = relationship("Product", back_populates="brand")
class Dispensary(Base):
__tablename__ = "dispensaries"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(255), nullable=False)
slug = Column(String(255), unique=True, nullable=False)
address = Column(Text)
city = Column(String(100))
state = Column(String(50))
zip_code = Column(String(20))
phone = Column(String(50))
website = Column(String(500))
logo_url = Column(Text)
latitude = Column(Float)
longitude = Column(Float)
rating = Column(Float)
review_count = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
prices = relationship("ProductPrice", back_populates="dispensary")
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(500), nullable=False, index=True)
slug = Column(String(500), nullable=False)
description = Column(Text)
image_url = Column(Text)
# Category and Brand
category_id = Column(Integer, ForeignKey("categories.id"))
brand_id = Column(Integer, ForeignKey("brands.id"))
# Cannabis-specific fields
strain_type = Column(String(50)) # indica, sativa, hybrid
thc_percentage = Column(Float)
cbd_percentage = Column(Float)
weight = Column(String(50)) # 1g, 3.5g, 7g, etc.
# Pricing (lowest/avg for display)
lowest_price = Column(Float)
avg_price = Column(Float)
# Deal info
has_deal = Column(Boolean, default=False)
deal_text = Column(String(255))
original_price = Column(Float)
# Metrics
dispensary_count = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
category = relationship("Category", back_populates="products")
brand = relationship("Brand", back_populates="products")
prices = relationship("ProductPrice", back_populates="product")
class ProductPrice(Base):
__tablename__ = "product_prices"
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
dispensary_id = Column(Integer, ForeignKey("dispensaries.id"), nullable=False)
price = Column(Float, nullable=False)
original_price = Column(Float) # For deals
in_stock = Column(Boolean, default=True)
# Deal info
has_deal = Column(Boolean, default=False)
deal_text = Column(String(255))
deal_expires = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
product = relationship("Product", back_populates="prices")
dispensary = relationship("Dispensary", back_populates="prices")

View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
name = Column(String(255))
phone = Column(String(50))
location = Column(String(255))
avatar_url = Column(Text)
is_active = Column(Boolean, default=True)
is_verified = Column(Boolean, default=False)
# Notification preferences (stored as JSON or separate columns)
notify_price_alerts = Column(Boolean, default=True)
notify_new_products = Column(Boolean, default=True)
notify_deals = Column(Boolean, default=True)
notify_newsletter = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
from app.routes import auth, products, categories, brands, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(products.router, prefix="/products", tags=["products"])
api_router.include_router(categories.router, prefix="/categories", tags=["categories"])
api_router.include_router(brands.router, prefix="/brands", tags=["brands"])
api_router.include_router(users.router, prefix="/users", tags=["users"])

View File

@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from passlib.context import CryptContext
from jose import jwt
from datetime import datetime, timedelta
from app.database import get_db
from app.config import get_settings
from app.models.user import User
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
router = APIRouter()
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
@router.post("/register", response_model=Token)
async def register(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if user exists
result = await db.execute(select(User).where(User.email == user_data.email))
if result.scalar_one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_password = get_password_hash(user_data.password)
user = User(
email=user_data.email,
hashed_password=hashed_password,
name=user_data.name,
)
db.add(user)
await db.commit()
await db.refresh(user)
# Create access token
access_token = create_access_token({"sub": str(user.id)})
return Token(access_token=access_token)
@router.post("/login", response_model=Token)
async def login(user_data: UserLogin, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == user_data.email))
user = result.scalar_one_or_none()
if not user or not verify_password(user_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User account is disabled"
)
access_token = create_access_token({"sub": str(user.id)})
return Token(access_token=access_token)

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models.product import Brand
from app.schemas.product import BrandResponse
router = APIRouter()
@router.get("", response_model=List[BrandResponse])
async def get_brands(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Brand).order_by(Brand.name.asc())
)
brands = result.scalars().all()
return [BrandResponse.model_validate(b) for b in brands]
@router.get("/{slug}", response_model=BrandResponse)
async def get_brand(slug: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Brand).where(Brand.slug == slug)
)
brand = result.scalar_one_or_none()
if not brand:
raise HTTPException(status_code=404, detail="Brand not found")
return BrandResponse.model_validate(brand)

View File

@@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import List
from app.database import get_db
from app.models.product import Category
from app.schemas.product import CategoryResponse
router = APIRouter()
@router.get("", response_model=List[CategoryResponse])
async def get_categories(db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Category).order_by(Category.name.asc())
)
categories = result.scalars().all()
return [CategoryResponse.model_validate(c) for c in categories]
@router.get("/{slug}", response_model=CategoryResponse)
async def get_category(slug: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(Category).where(Category.slug == slug)
)
category = result.scalar_one_or_none()
if not category:
raise HTTPException(status_code=404, detail="Category not found")
return CategoryResponse.model_validate(category)

View File

@@ -0,0 +1,135 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
from typing import Optional, List
from app.database import get_db
from app.models.product import Product, ProductPrice, Category, Brand
from app.schemas.product import ProductResponse, ProductListResponse, ProductDetailResponse
router = APIRouter()
@router.get("", response_model=ProductListResponse)
async def get_products(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
category: Optional[str] = None,
brand: Optional[str] = None,
strain_type: Optional[str] = None,
min_price: Optional[float] = None,
max_price: Optional[float] = None,
min_thc: Optional[float] = None,
has_deal: Optional[bool] = None,
search: Optional[str] = None,
sort: str = Query("name", enum=["name", "price_low", "price_high", "newest", "thc"]),
db: AsyncSession = Depends(get_db),
):
query = select(Product).where(Product.is_active == True)
# Filters
if category:
query = query.join(Category).where(Category.slug == category)
if brand:
query = query.join(Brand).where(Brand.slug == brand)
if strain_type:
query = query.where(Product.strain_type == strain_type)
if min_price is not None:
query = query.where(Product.lowest_price >= min_price)
if max_price is not None:
query = query.where(Product.lowest_price <= max_price)
if min_thc is not None:
query = query.where(Product.thc_percentage >= min_thc)
if has_deal:
query = query.where(Product.has_deal == True)
if search:
query = query.where(Product.name.ilike(f"%{search}%"))
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar()
# Sorting
if sort == "price_low":
query = query.order_by(Product.lowest_price.asc())
elif sort == "price_high":
query = query.order_by(Product.lowest_price.desc())
elif sort == "newest":
query = query.order_by(Product.created_at.desc())
elif sort == "thc":
query = query.order_by(Product.thc_percentage.desc().nullslast())
else:
query = query.order_by(Product.name.asc())
# Pagination
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
query = query.options(selectinload(Product.category), selectinload(Product.brand))
result = await db.execute(query)
products = result.scalars().all()
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
page=page,
per_page=per_page,
total_pages=(total + per_page - 1) // per_page,
)
@router.get("/deals", response_model=ProductListResponse)
async def get_deals(
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
query = (
select(Product)
.where(Product.is_active == True, Product.has_deal == True)
.order_by(Product.updated_at.desc())
)
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar()
# Pagination
offset = (page - 1) * per_page
query = query.offset(offset).limit(per_page)
query = query.options(selectinload(Product.category), selectinload(Product.brand))
result = await db.execute(query)
products = result.scalars().all()
return ProductListResponse(
products=[ProductResponse.model_validate(p) for p in products],
total=total,
page=page,
per_page=per_page,
total_pages=(total + per_page - 1) // per_page,
)
@router.get("/{product_id}", response_model=ProductDetailResponse)
async def get_product(product_id: int, db: AsyncSession = Depends(get_db)):
query = (
select(Product)
.where(Product.id == product_id)
.options(
selectinload(Product.category),
selectinload(Product.brand),
selectinload(Product.prices).selectinload(ProductPrice.dispensary),
)
)
result = await db.execute(query)
product = result.scalar_one_or_none()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return ProductDetailResponse.model_validate(product)

View File

@@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from jose import jwt, JWTError
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from app.database import get_db
from app.config import get_settings
from app.models.user import User
from app.schemas.user import UserResponse, UserUpdate
router = APIRouter()
settings = get_settings()
security = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
try:
payload = jwt.decode(
credentials.credentials,
settings.secret_key,
algorithms=[settings.algorithm],
)
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
result = await db.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.is_active:
raise HTTPException(status_code=401, detail="User account is disabled")
return user
@router.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return UserResponse.model_validate(current_user)
@router.patch("/me", response_model=UserResponse)
async def update_me(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
await db.commit()
await db.refresh(current_user)
return UserResponse.model_validate(current_user)

View File

@@ -0,0 +1,20 @@
from app.schemas.user import UserCreate, UserLogin, UserResponse, Token
from app.schemas.product import (
ProductResponse,
ProductListResponse,
CategoryResponse,
BrandResponse,
DispensaryResponse,
)
__all__ = [
"UserCreate",
"UserLogin",
"UserResponse",
"Token",
"ProductResponse",
"ProductListResponse",
"CategoryResponse",
"BrandResponse",
"DispensaryResponse",
]

View File

@@ -0,0 +1,96 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List
class CategoryResponse(BaseModel):
id: int
name: str
slug: str
description: Optional[str] = None
icon: Optional[str] = None
image_url: Optional[str] = None
product_count: int = 0
class Config:
from_attributes = True
class BrandResponse(BaseModel):
id: int
name: str
slug: str
description: Optional[str] = None
logo_url: Optional[str] = None
website: Optional[str] = None
product_count: int = 0
class Config:
from_attributes = True
class DispensaryResponse(BaseModel):
id: int
name: str
slug: str
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
phone: Optional[str] = None
website: Optional[str] = None
logo_url: Optional[str] = None
rating: Optional[float] = None
review_count: int = 0
latitude: Optional[float] = None
longitude: Optional[float] = None
class Config:
from_attributes = True
class ProductPriceResponse(BaseModel):
dispensary: DispensaryResponse
price: float
original_price: Optional[float] = None
in_stock: bool = True
has_deal: bool = False
deal_text: Optional[str] = None
class Config:
from_attributes = True
class ProductResponse(BaseModel):
id: int
name: str
slug: str
description: Optional[str] = None
image_url: Optional[str] = None
category: Optional[CategoryResponse] = None
brand: Optional[BrandResponse] = None
strain_type: Optional[str] = None
thc_percentage: Optional[float] = None
cbd_percentage: Optional[float] = None
weight: Optional[str] = None
lowest_price: Optional[float] = None
avg_price: Optional[float] = None
has_deal: bool = False
deal_text: Optional[str] = None
original_price: Optional[float] = None
dispensary_count: int = 0
created_at: datetime
class Config:
from_attributes = True
class ProductDetailResponse(ProductResponse):
prices: List[ProductPriceResponse] = []
class ProductListResponse(BaseModel):
products: List[ProductResponse]
total: int
page: int
per_page: int
total_pages: int

View File

@@ -0,0 +1,49 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import Optional
class UserBase(BaseModel):
email: EmailStr
name: Optional[str] = None
class UserCreate(UserBase):
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
location: Optional[str] = None
notify_price_alerts: Optional[bool] = None
notify_new_products: Optional[bool] = None
notify_deals: Optional[bool] = None
notify_newsletter: Optional[bool] = None
class UserResponse(UserBase):
id: int
phone: Optional[str] = None
location: Optional[str] = None
avatar_url: Optional[str] = None
is_active: bool
is_verified: bool
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: Optional[int] = None

44
findagram/backend/main.py Normal file
View File

@@ -0,0 +1,44 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import get_settings
from app.routes import api_router
settings = get_settings()
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="Find a Gram - Cannabis Product Search & Price Comparison API",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API routes
app.include_router(api_router, prefix="/api")
@app.get("/")
async def root():
return {
"name": settings.app_name,
"version": settings.app_version,
"status": "running",
}
@app.get("/health")
async def health():
return {"status": "healthy"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8001, reload=True)

View File

@@ -0,0 +1,13 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
pydantic==2.5.3
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
asyncpg==0.29.0
sqlalchemy[asyncio]==2.0.25
alembic==1.13.1
httpx==0.26.0
email-validator==2.1.0
python-dotenv==1.0.0

View File

@@ -0,0 +1,7 @@
node_modules
build
.env.local
.env.*.local
npm-debug.log*
.DS_Store
*.log

View File

@@ -0,0 +1,3 @@
# Development environment
# API URL for local development
REACT_APP_API_URL=http://localhost:3010

View File

@@ -0,0 +1,7 @@
# Findagram Frontend Environment Variables
# Copy this file to .env.development or .env.production
# API URL for backend endpoints
# Local development: http://localhost:3010
# Production: leave empty (uses relative path via ingress)
REACT_APP_API_URL=http://localhost:3010

View File

@@ -0,0 +1,3 @@
# Production environment
# Empty = uses relative path (proxied via ingress)
REACT_APP_API_URL=

23
findagram/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Dependencies
node_modules/
# Build output
build/
dist/
# Environment files with local overrides
.env.local
.env.development.local
.env.production.local
# Editor directories
.idea/
.vscode/
# OS files
.DS_Store
# Debug logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*

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.findagram.co
# 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;"]

17809
findagram/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
{
"name": "findagram-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@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-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",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"lucide-react": "^0.312.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.3",
"react-scripts": "5.0.1",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7"
},
"scripts": {
"start": "PORT=3001 react-scripts start",
"dev": "REACT_APP_API_URL=http://localhost:3010 PORT=3001 react-scripts start",
"prod": "REACT_APP_API_URL=https://findagram.co PORT=3001 react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1"
}
}

View File

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

View File

@@ -0,0 +1,20 @@
<!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="#8B5CF6" />
<meta name="description" content="Find a Gram - Cannabis Product Search & Price Comparison. Find the best prices on cannabis products near you." />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Find a Gram - Cannabis Product Finder</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
{
"short_name": "Find a Gram",
"name": "Find a Gram - Cannabis Product Finder",
"description": "Find cannabis products, compare prices, and get deals",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#8B5CF6",
"background_color": "#ffffff",
"orientation": "portrait-primary",
"categories": ["lifestyle", "shopping"]
}

View File

@@ -0,0 +1,112 @@
/* eslint-disable no-restricted-globals */
const CACHE_NAME = 'findagram-v1';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json',
];
// Install service worker
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
self.skipWaiting();
});
// Fetch event - network first, fallback to cache
self.addEventListener('fetch', (event) => {
// Skip non-GET requests
if (event.request.method !== 'GET') return;
// Skip chrome-extension requests
if (event.request.url.startsWith('chrome-extension://')) return;
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone the response
const responseClone = response.clone();
// Cache successful responses
if (response.status === 200) {
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseClone);
});
}
return response;
})
.catch(() => {
// Fallback to cache
return caches.match(event.request).then((response) => {
if (response) {
return response;
}
// Return offline page for navigation requests
if (event.request.mode === 'navigate') {
return caches.match('/');
}
return new Response('Offline', { status: 503 });
});
})
);
});
// Activate event - clean old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => cacheName !== CACHE_NAME)
.map((cacheName) => caches.delete(cacheName))
);
})
);
self.clients.claim();
});
// Push notification handling
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body || 'New notification from Find a Gram',
icon: '/logo192.png',
badge: '/logo192.png',
vibrate: [100, 50, 100],
data: {
url: data.url || '/',
},
};
event.waitUntil(
self.registration.showNotification(data.title || 'Find a Gram', options)
);
});
// Notification click handling
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
self.clients.matchAll({ type: 'window' }).then((clientList) => {
// If there's already a window open, focus it
for (const client of clientList) {
if (client.url === event.notification.data.url && 'focus' in client) {
return client.focus();
}
}
// Otherwise, open a new window
if (self.clients.openWindow) {
return self.clients.openWindow(event.notification.data.url);
}
})
);
});

View File

@@ -0,0 +1,86 @@
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Header from './components/findagram/Header';
import Footer from './components/findagram/Footer';
// Pages
import Home from './pages/findagram/Home';
import Products from './pages/findagram/Products';
import ProductDetail from './pages/findagram/ProductDetail';
import Deals from './pages/findagram/Deals';
import Brands from './pages/findagram/Brands';
import BrandDetail from './pages/findagram/BrandDetail';
import Categories from './pages/findagram/Categories';
import CategoryDetail from './pages/findagram/CategoryDetail';
import About from './pages/findagram/About';
import Contact from './pages/findagram/Contact';
import Login from './pages/findagram/Login';
import Signup from './pages/findagram/Signup';
import Dashboard from './pages/findagram/Dashboard';
import Favorites from './pages/findagram/Favorites';
import Alerts from './pages/findagram/Alerts';
import SavedSearches from './pages/findagram/SavedSearches';
import Profile from './pages/findagram/Profile';
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState(null);
// Mock login function
const handleLogin = (email, password) => {
// In a real app, this would make an API call
setUser({
id: 1,
name: 'John Doe',
email: email,
avatar: null,
});
setIsLoggedIn(true);
return true;
};
// Mock logout function
const handleLogout = () => {
setUser(null);
setIsLoggedIn(false);
};
return (
<Router>
<div className="flex flex-col min-h-screen">
<Header isLoggedIn={isLoggedIn} user={user} onLogout={handleLogout} />
<main className="flex-grow">
<Routes>
{/* Public Routes */}
<Route path="/" element={<Home />} />
<Route path="/products" element={<Products />} />
<Route path="/products/:id" element={<ProductDetail />} />
<Route path="/deals" element={<Deals />} />
<Route path="/brands" element={<Brands />} />
<Route path="/brands/:slug" element={<BrandDetail />} />
<Route path="/categories" element={<Categories />} />
<Route path="/categories/:slug" element={<CategoryDetail />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
{/* Auth Routes */}
<Route path="/login" element={<Login onLogin={handleLogin} />} />
<Route path="/signup" element={<Signup onLogin={handleLogin} />} />
{/* Dashboard Routes */}
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/dashboard/favorites" element={<Favorites />} />
<Route path="/dashboard/alerts" element={<Alerts />} />
<Route path="/dashboard/searches" element={<SavedSearches />} />
<Route path="/dashboard/settings" element={<Profile />} />
</Routes>
</main>
<Footer />
</div>
</Router>
);
}
export default App;

View File

@@ -0,0 +1,417 @@
/**
* Findagram API Client
*
* Connects to the backend /api/az/* endpoints which are publicly accessible.
* Uses REACT_APP_API_URL environment variable for the base URL.
*
* Local development: http://localhost:3010
* Production: https://findagram.co (proxied to backend via ingress)
*/
const API_BASE_URL = process.env.REACT_APP_API_URL || '';
/**
* Make a fetch request to the API
*/
async function request(endpoint, options = {}) {
const url = `${API_BASE_URL}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}
/**
* Build query string from params object
*/
function buildQueryString(params) {
const filtered = Object.entries(params).filter(([_, v]) => v !== undefined && v !== null && v !== '');
if (filtered.length === 0) return '';
return '?' + new URLSearchParams(filtered).toString();
}
// ============================================================
// PRODUCTS
// ============================================================
/**
* Search/filter products across all dispensaries
*
* @param {Object} params
* @param {string} [params.search] - Search term (name or brand)
* @param {string} [params.type] - Category type (flower, concentrates, edibles, etc.)
* @param {string} [params.subcategory] - Subcategory
* @param {string} [params.brandName] - Brand name filter
* @param {string} [params.stockStatus] - Stock status (in_stock, out_of_stock, unknown)
* @param {number} [params.storeId] - Filter to specific store
* @param {number} [params.limit=50] - Page size
* @param {number} [params.offset=0] - Offset for pagination
*/
export async function getProducts(params = {}) {
const queryString = buildQueryString({
search: params.search,
type: params.type,
subcategory: params.subcategory,
brandName: params.brandName,
stockStatus: params.stockStatus,
storeId: params.storeId,
limit: params.limit || 50,
offset: params.offset || 0,
});
return request(`/api/az/products${queryString}`);
}
/**
* Get a single product by ID
*/
export async function getProduct(id) {
return request(`/api/az/products/${id}`);
}
/**
* Get product availability - which dispensaries carry this product
*
* @param {number|string} productId - Product ID
* @param {Object} params
* @param {number} params.lat - User latitude
* @param {number} params.lng - User longitude
* @param {number} [params.maxRadiusMiles=50] - Maximum search radius in miles
* @returns {Promise<{productId: number, productName: string, brandName: string, totalCount: number, offers: Array}>}
*/
export async function getProductAvailability(productId, params = {}) {
const { lat, lng, maxRadiusMiles = 50 } = params;
if (!lat || !lng) {
throw new Error('lat and lng are required');
}
const queryString = buildQueryString({
lat,
lng,
max_radius_miles: maxRadiusMiles,
});
return request(`/api/az/products/${productId}/availability${queryString}`);
}
/**
* Get similar products (same brand + category)
*
* @param {number|string} productId - Product ID
* @returns {Promise<{similarProducts: Array<{productId: number, name: string, brandName: string, imageUrl: string, price: number}>}>}
*/
export async function getSimilarProducts(productId) {
return request(`/api/az/products/${productId}/similar`);
}
/**
* Get products for a specific store with filters
*/
export async function getStoreProducts(storeId, params = {}) {
const queryString = buildQueryString({
search: params.search,
type: params.type,
subcategory: params.subcategory,
brandName: params.brandName,
stockStatus: params.stockStatus,
limit: params.limit || 50,
offset: params.offset || 0,
});
return request(`/api/az/stores/${storeId}/products${queryString}`);
}
// ============================================================
// DISPENSARIES (STORES)
// ============================================================
/**
* Get all dispensaries
*
* @param {Object} params
* @param {string} [params.city] - Filter by city
* @param {boolean} [params.hasPlatformId] - Filter by platform ID presence
* @param {number} [params.limit=100] - Page size
* @param {number} [params.offset=0] - Offset
*/
export async function getDispensaries(params = {}) {
const queryString = buildQueryString({
city: params.city,
hasPlatformId: params.hasPlatformId,
limit: params.limit || 100,
offset: params.offset || 0,
});
return request(`/api/az/stores${queryString}`);
}
/**
* Get a single dispensary by ID
*/
export async function getDispensary(id) {
return request(`/api/az/stores/${id}`);
}
/**
* Get dispensary by slug or platform ID
*/
export async function getDispensaryBySlug(slug) {
return request(`/api/az/stores/slug/${slug}`);
}
/**
* Get dispensary summary (product counts, categories, brands)
*/
export async function getDispensarySummary(id) {
return request(`/api/az/stores/${id}/summary`);
}
/**
* Get brands available at a specific dispensary
*/
export async function getDispensaryBrands(id) {
return request(`/api/az/stores/${id}/brands`);
}
/**
* Get categories available at a specific dispensary
*/
export async function getDispensaryCategories(id) {
return request(`/api/az/stores/${id}/categories`);
}
// ============================================================
// CATEGORIES
// ============================================================
/**
* Get all categories with product counts
*/
export async function getCategories() {
return request('/api/az/categories');
}
// ============================================================
// BRANDS
// ============================================================
/**
* Get all brands with product counts
*
* @param {Object} params
* @param {number} [params.limit=100] - Page size
* @param {number} [params.offset=0] - Offset
*/
export async function getBrands(params = {}) {
const queryString = buildQueryString({
limit: params.limit || 100,
offset: params.offset || 0,
});
return request(`/api/az/brands${queryString}`);
}
// ============================================================
// DEALS / SPECIALS
// Note: The /api/az routes don't have a dedicated specials endpoint yet.
// For now, we can filter products with sale prices or use dispensary-specific specials.
// ============================================================
/**
* Get products on sale (products where sale_price exists)
* This is a client-side filter until a dedicated endpoint is added.
*/
export async function getDeals(params = {}) {
// For now, get products and we'll need to filter client-side
// or we could use the /api/dispensaries/:slug/specials endpoint if we have a dispensary context
const result = await getProducts({
...params,
limit: params.limit || 100,
});
// Filter to only products with a sale price
// Note: This is a temporary solution - ideally the backend would support this filter
return {
...result,
products: result.products.filter(p => p.sale_price || p.med_sale_price),
};
}
// ============================================================
// SEARCH (convenience wrapper)
// ============================================================
/**
* Search products by term
*/
export async function searchProducts(searchTerm, params = {}) {
return getProducts({
...params,
search: searchTerm,
});
}
// ============================================================
// FIELD MAPPING HELPERS
// ============================================================
/**
* Map API product to UI-compatible format
* Backend returns snake_case, UI expects camelCase with specific field names
*
* @param {Object} apiProduct - Product from API
* @returns {Object} - Product formatted for UI components
*/
export function mapProductForUI(apiProduct) {
// Handle both direct product and transformed product formats
const p = apiProduct;
return {
id: p.id,
name: p.name,
brand: p.brand || p.brand_name,
category: p.type || p.category,
subcategory: p.subcategory,
strainType: p.strain_type || null,
// Images
image: p.image_url || p.primary_image_url || null,
// Potency
thc: p.thc_percentage || p.thc_content || null,
cbd: p.cbd_percentage || p.cbd_content || null,
// Prices (API returns dollars as numbers or null)
price: p.regular_price || null,
priceRange: p.regular_price_max && p.regular_price
? { min: p.regular_price, max: p.regular_price_max }
: null,
onSale: !!(p.sale_price || p.med_sale_price),
salePrice: p.sale_price || null,
medPrice: p.med_price || null,
medSalePrice: p.med_sale_price || null,
// Stock
inStock: p.in_stock !== undefined ? p.in_stock : p.stock_status === 'in_stock',
stockStatus: p.stock_status,
// Store info (if available)
storeName: p.store_name,
storeCity: p.store_city,
storeSlug: p.store_slug,
dispensaryId: p.dispensary_id,
// Options/variants
options: p.options,
totalQuantity: p.total_quantity,
// Timestamps
updatedAt: p.updated_at || p.snapshot_at,
// For compatibility with ProductCard expectations
rating: null, // Not available from API
reviewCount: null, // Not available from API
dispensaries: [], // Not populated in list view
dispensaryCount: p.store_name ? 1 : 0,
};
}
/**
* Map API category to UI-compatible format
*/
export function mapCategoryForUI(apiCategory) {
return {
id: apiCategory.type,
name: formatCategoryName(apiCategory.type),
slug: apiCategory.type?.toLowerCase().replace(/\s+/g, '-'),
subcategory: apiCategory.subcategory,
productCount: parseInt(apiCategory.product_count || 0, 10),
dispensaryCount: parseInt(apiCategory.dispensary_count || 0, 10),
brandCount: parseInt(apiCategory.brand_count || 0, 10),
};
}
/**
* Map API brand to UI-compatible format
*/
export function mapBrandForUI(apiBrand) {
return {
id: apiBrand.brand_name,
name: apiBrand.brand_name,
slug: apiBrand.brand_name?.toLowerCase().replace(/\s+/g, '-'),
logo: apiBrand.brand_logo_url || null,
productCount: parseInt(apiBrand.product_count || 0, 10),
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
productTypes: apiBrand.product_types || [],
};
}
/**
* Map API dispensary to UI-compatible format
*/
export function mapDispensaryForUI(apiDispensary) {
return {
id: apiDispensary.id,
name: apiDispensary.dba_name || apiDispensary.name,
slug: apiDispensary.slug,
city: apiDispensary.city,
state: apiDispensary.state,
address: apiDispensary.address,
zip: apiDispensary.zip,
latitude: apiDispensary.latitude,
longitude: apiDispensary.longitude,
website: apiDispensary.website,
menuUrl: apiDispensary.menu_url,
// Summary data (if fetched with summary)
productCount: apiDispensary.totalProducts,
brandCount: apiDispensary.brandCount,
categoryCount: apiDispensary.categoryCount,
inStockCount: apiDispensary.inStockCount,
};
}
/**
* Format category name for display
*/
function formatCategoryName(type) {
if (!type) return '';
// Convert "FLOWER" to "Flower", "PRE_ROLLS" to "Pre Rolls", etc.
return type
.toLowerCase()
.replace(/_/g, ' ')
.replace(/\b\w/g, c => c.toUpperCase());
}
// Default export for convenience
const api = {
// Products
getProducts,
getProduct,
getProductAvailability,
getSimilarProducts,
getStoreProducts,
searchProducts,
// Dispensaries
getDispensaries,
getDispensary,
getDispensaryBySlug,
getDispensarySummary,
getDispensaryBrands,
getDispensaryCategories,
// Categories & Brands
getCategories,
getBrands,
// Deals
getDeals,
// Mappers
mapProductForUI,
mapCategoryForUI,
mapBrandForUI,
mapDispensaryForUI,
};
export default api;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Leaf, Mail, MapPin, Phone, Facebook, Twitter, Instagram } from 'lucide-react';
const Footer = () => {
const currentYear = new Date().getFullYear();
const productLinks = [
{ name: 'Browse All', href: '/products' },
{ name: 'Flower', href: '/categories/flower' },
{ name: 'Concentrates', href: '/categories/concentrates' },
{ name: 'Edibles', href: '/categories/edibles' },
{ name: 'Vapes', href: '/categories/vapes' },
{ name: 'Pre-Rolls', href: '/categories/pre-rolls' },
];
const companyLinks = [
{ name: 'About Us', href: '/about' },
{ name: 'Contact', href: '/contact' },
{ name: 'Careers', href: '/careers' },
{ name: 'Press', href: '/press' },
{ name: 'Blog', href: '/blog' },
];
const supportLinks = [
{ name: 'Help Center', href: '/help' },
{ name: 'FAQs', href: '/faqs' },
{ name: 'Privacy Policy', href: '/privacy' },
{ name: 'Terms of Service', href: '/terms' },
{ name: 'Cookie Policy', href: '/cookies' },
];
const socialLinks = [
{ name: 'Facebook', icon: Facebook, href: '#' },
{ name: 'Twitter', icon: Twitter, href: '#' },
{ name: 'Instagram', icon: Instagram, href: '#' },
];
return (
<footer className="bg-gray-900 text-white">
{/* Main Footer Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{/* Brand Column */}
<div className="space-y-4">
<Link to="/" className="flex items-center space-x-2">
<div className="w-10 h-10 rounded-full gradient-purple flex items-center justify-center">
<Leaf className="h-6 w-6 text-white" />
</div>
<span className="text-xl font-bold">
Find a <span className="text-primary-400">Gram</span>
</span>
</Link>
<p className="text-gray-400 text-sm">
Find the best cannabis products and prices near you. Compare prices,
discover new brands, and get deals delivered to your inbox.
</p>
<div className="flex space-x-4">
{socialLinks.map((social) => (
<a
key={social.name}
href={social.href}
className="text-gray-400 hover:text-primary-400 transition-colors"
aria-label={social.name}
>
<social.icon className="h-5 w-5" />
</a>
))}
</div>
</div>
{/* Products Column */}
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary-400 mb-4">
Products
</h3>
<ul className="space-y-2">
{productLinks.map((link) => (
<li key={link.name}>
<Link
to={link.href}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Company Column */}
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary-400 mb-4">
Company
</h3>
<ul className="space-y-2">
{companyLinks.map((link) => (
<li key={link.name}>
<Link
to={link.href}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
{link.name}
</Link>
</li>
))}
</ul>
</div>
{/* Support Column */}
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-primary-400 mb-4">
Support
</h3>
<ul className="space-y-2">
{supportLinks.map((link) => (
<li key={link.name}>
<Link
to={link.href}
className="text-gray-400 hover:text-white transition-colors text-sm"
>
{link.name}
</Link>
</li>
))}
</ul>
<div className="mt-6 space-y-2">
<a
href="mailto:support@findagram.co"
className="flex items-center text-gray-400 hover:text-white transition-colors text-sm"
>
<Mail className="h-4 w-4 mr-2" />
support@findagram.co
</a>
</div>
</div>
</div>
</div>
{/* Bottom Bar */}
<div className="border-t border-gray-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col md:flex-row justify-between items-center space-y-4 md:space-y-0">
<p className="text-gray-400 text-sm">
&copy; {currentYear} Find a Gram. All rights reserved.
</p>
<div className="flex items-center space-x-4 text-sm text-gray-400">
<span className="flex items-center">
<MapPin className="h-4 w-4 mr-1" />
Arizona
</span>
<span>|</span>
<span>21+ Only</span>
<span>|</span>
<span>Medical & Recreational</span>
</div>
</div>
</div>
</div>
{/* Age Verification Notice */}
<div className="bg-gray-800 py-3">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-xs text-gray-500 text-center">
This website is intended for adults 21 years of age and older.
Leaf products are for use by adults only. Please consume responsibly.
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
import {
Search,
Menu,
X,
Heart,
Bell,
User,
LogOut,
Settings,
Bookmark,
Leaf,
Tag,
LayoutGrid,
Store,
} from 'lucide-react';
const Header = ({ isLoggedIn = false, user = null }) => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const location = useLocation();
const navigation = [
{ name: 'Products', href: '/products', icon: LayoutGrid },
{ name: 'Deals', href: '/deals', icon: Tag },
{ name: 'Brands', href: '/brands', icon: Leaf },
{ name: 'Categories', href: '/categories', icon: Store },
];
const isActive = (path) => location.pathname === path;
const handleSearch = (e) => {
e.preventDefault();
if (searchQuery.trim()) {
window.location.href = `/products?search=${encodeURIComponent(searchQuery)}`;
}
};
return (
<header className="sticky top-0 z-50 bg-white border-b border-gray-200 shadow-sm">
{/* Top bar with purple gradient */}
<div className="gradient-purple h-1" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center space-x-2">
<div className="w-10 h-10 rounded-full gradient-purple flex items-center justify-center">
<Leaf className="h-6 w-6 text-white" />
</div>
<span className="text-xl font-bold text-gray-900">
Find a <span className="text-primary">Gram</span>
</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-6">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
className={`flex items-center space-x-1 text-sm font-medium transition-colors ${
isActive(item.href)
? 'text-primary'
: 'text-gray-600 hover:text-primary'
}`}
>
<item.icon className="h-4 w-4" />
<span>{item.name}</span>
</Link>
))}
</nav>
{/* Search Bar (Desktop) */}
<form onSubmit={handleSearch} className="hidden lg:flex items-center flex-1 max-w-md mx-8">
<div className="relative w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search products, brands..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 w-full bg-gray-50 border-gray-200 focus:bg-white"
/>
</div>
</form>
{/* Right side actions */}
<div className="flex items-center space-x-4">
{isLoggedIn ? (
<>
{/* Favorites */}
<Link to="/dashboard/favorites" className="hidden sm:block">
<Button variant="ghost" size="icon" className="text-gray-600 hover:text-primary">
<Heart className="h-5 w-5" />
</Button>
</Link>
{/* Alerts */}
<Link to="/dashboard/alerts" className="hidden sm:block">
<Button variant="ghost" size="icon" className="relative text-gray-600 hover:text-primary">
<Bell className="h-5 w-5" />
<span className="absolute top-0 right-0 h-2 w-2 bg-pink-500 rounded-full" />
</Button>
</Link>
{/* User Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10 border-2 border-primary">
<AvatarImage src={user?.avatar} alt={user?.name} />
<AvatarFallback className="bg-primary text-white">
{user?.name?.charAt(0) || 'U'}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user?.name || 'User'}</p>
<p className="text-xs leading-none text-muted-foreground">
{user?.email || 'user@example.com'}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/dashboard" className="flex items-center">
<User className="mr-2 h-4 w-4" />
Dashboard
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/favorites" className="flex items-center">
<Heart className="mr-2 h-4 w-4" />
Favorites
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/alerts" className="flex items-center">
<Bell className="mr-2 h-4 w-4" />
Price Alerts
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/dashboard/searches" className="flex items-center">
<Bookmark className="mr-2 h-4 w-4" />
Saved Searches
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/dashboard/settings" className="flex items-center">
<Settings className="mr-2 h-4 w-4" />
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<Link to="/login" className="hidden sm:block">
<Button variant="ghost" className="text-gray-600">
Log in
</Button>
</Link>
<Link to="/signup">
<Button className="gradient-purple text-white hover:opacity-90">
Sign up
</Button>
</Link>
</>
)}
{/* Mobile menu button */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
</Button>
</div>
</div>
{/* Mobile Search */}
<form onSubmit={handleSearch} className="lg:hidden pb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search products, brands..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 w-full bg-gray-50 border-gray-200"
/>
</div>
</form>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-gray-200 bg-white">
<div className="px-4 py-4 space-y-2">
{navigation.map((item) => (
<Link
key={item.name}
to={item.href}
onClick={() => setMobileMenuOpen(false)}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg ${
isActive(item.href)
? 'bg-primary/10 text-primary'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<item.icon className="h-5 w-5" />
<span className="font-medium">{item.name}</span>
</Link>
))}
{isLoggedIn && (
<>
<div className="border-t border-gray-200 my-2" />
<Link
to="/dashboard/favorites"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-100"
>
<Heart className="h-5 w-5" />
<span className="font-medium">Favorites</span>
</Link>
<Link
to="/dashboard/alerts"
onClick={() => setMobileMenuOpen(false)}
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-gray-600 hover:bg-gray-100"
>
<Bell className="h-5 w-5" />
<span className="font-medium">Price Alerts</span>
</Link>
</>
)}
</div>
</div>
)}
</header>
);
};
export default Header;

View File

@@ -0,0 +1,161 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent } from '../ui/card';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Heart, Star, MapPin, TrendingDown } from 'lucide-react';
const ProductCard = ({
product,
onFavorite,
isFavorite = false,
showDispensaryCount = true
}) => {
const {
id,
name,
brand,
category,
image,
thc,
cbd,
price,
priceRange,
rating,
reviewCount,
strainType,
dispensaries = [],
onSale,
salePrice,
} = product;
const strainColors = {
sativa: 'bg-yellow-100 text-yellow-800',
indica: 'bg-purple-100 text-purple-800',
hybrid: 'bg-green-100 text-green-800',
};
const savings = onSale && salePrice ? ((price - salePrice) / price * 100).toFixed(0) : 0;
return (
<Card className="product-card group overflow-hidden">
<Link to={`/products/${id}`}>
{/* Image Container */}
<div className="relative aspect-square overflow-hidden bg-gray-100">
<img
src={image || '/placeholder-product.jpg'}
alt={name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
{/* Badges */}
<div className="absolute top-3 left-3 flex flex-col gap-2">
{onSale && (
<Badge variant="deal" className="flex items-center gap-1">
<TrendingDown className="h-3 w-3" />
{savings}% OFF
</Badge>
)}
{strainType && (
<Badge className={strainColors[strainType.toLowerCase()] || strainColors.hybrid}>
{strainType}
</Badge>
)}
</div>
{/* Favorite Button */}
<Button
variant="ghost"
size="icon"
className={`absolute top-3 right-3 h-8 w-8 rounded-full bg-white/80 hover:bg-white ${
isFavorite ? 'text-red-500' : 'text-gray-400'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFavorite?.(id);
}}
>
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
</Button>
</div>
</Link>
<CardContent className="p-4">
{/* Brand */}
<p className="text-xs text-muted-foreground uppercase tracking-wide mb-1">
{brand}
</p>
{/* Product Name */}
<Link to={`/products/${id}`}>
<h3 className="font-semibold text-gray-900 line-clamp-2 hover:text-primary transition-colors mb-2">
{name}
</h3>
</Link>
{/* Category & THC/CBD */}
<div className="flex items-center gap-2 text-sm text-gray-600 mb-3">
<span>{category}</span>
{thc && (
<>
<span className="text-gray-300"></span>
<span>THC {thc}%</span>
</>
)}
{cbd > 0 && (
<>
<span className="text-gray-300"></span>
<span>CBD {cbd}%</span>
</>
)}
</div>
{/* Rating */}
{rating && (
<div className="flex items-center gap-1 mb-3">
<Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
<span className="text-sm font-medium">{rating}</span>
{reviewCount && (
<span className="text-sm text-muted-foreground">
({reviewCount})
</span>
)}
</div>
)}
{/* Price */}
<div className="flex items-baseline gap-2 mb-3">
{onSale && salePrice ? (
<>
<span className="text-lg font-bold text-pink-600">
${salePrice.toFixed(2)}
</span>
<span className="text-sm text-gray-400 line-through">
${price.toFixed(2)}
</span>
</>
) : priceRange ? (
<span className="text-lg font-bold text-gray-900">
${priceRange.min.toFixed(2)} - ${priceRange.max.toFixed(2)}
</span>
) : (
<span className="text-lg font-bold text-gray-900">
${price.toFixed(2)}
</span>
)}
</div>
{/* Dispensary Count */}
{showDispensaryCount && dispensaries.length > 0 && (
<div className="flex items-center text-sm text-muted-foreground">
<MapPin className="h-4 w-4 mr-1" />
Available at {dispensaries.length} {dispensaries.length === 1 ? 'dispensary' : 'dispensaries'}
</div>
)}
</CardContent>
</Card>
);
};
export default ProductCard;

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "../../lib/utils";
const Avatar = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,37 @@
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-500 text-white hover:bg-green-600",
warning:
"border-transparent bg-amber-500 text-white hover:bg-amber-600",
deal:
"border-transparent bg-pink-500 text-white hover:bg-pink-600",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,49 @@
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 };

Some files were not shown because too many files have changed in this diff Show More