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:
229
CLAUDE.md
229
CLAUDE.md
@@ -1,5 +1,136 @@
|
|||||||
## Claude Guidelines for this Project
|
## 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
|
### 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.
|
- **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
|
```bash
|
||||||
pkill -f "port-forward.*dispensary-scraper"
|
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`)
|
||||||
|
|||||||
172
backend/migrations/035_multi_domain_users.sql
Normal file
172
backend/migrations/035_multi_domain_users.sql
Normal 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;
|
||||||
47
backend/migrations/036_findadispo_dispensary_fields.sql
Normal file
47
backend/migrations/036_findadispo_dispensary_fields.sql
Normal 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.';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dutchie-menus-backend",
|
"name": "dutchie-menus-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.5.1",
|
||||||
"description": "Backend API for Dutchie Menus scraper and management",
|
"description": "Backend API for Dutchie Menus scraper and management",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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
|
// CATEGORIES
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -64,8 +64,13 @@ import { dutchieAZRouter, startScheduler as startDutchieAZScheduler, initializeD
|
|||||||
import { trackApiUsage, checkRateLimit } from './middleware/apiTokenTracker';
|
import { trackApiUsage, checkRateLimit } from './middleware/apiTokenTracker';
|
||||||
import { startCrawlScheduler } from './services/crawl-scheduler';
|
import { startCrawlScheduler } from './services/crawl-scheduler';
|
||||||
import { validateWordPressPermissions } from './middleware/wordpressPermissions';
|
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);
|
app.use(validateWordPressPermissions);
|
||||||
|
|
||||||
// Apply API tracking middleware globally
|
// Apply API tracking middleware globally
|
||||||
|
|||||||
107
backend/src/middleware/trustedDomains.ts
Normal file
107
backend/src/middleware/trustedDomains.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -840,6 +840,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
|||||||
d.longitude,
|
d.longitude,
|
||||||
d.menu_type as platform,
|
d.menu_type as platform,
|
||||||
d.menu_url,
|
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.product_count, 0) as product_count,
|
||||||
COALESCE(pc.in_stock_count, 0) as in_stock_count,
|
COALESCE(pc.in_stock_count, 0) as in_stock_count,
|
||||||
pc.last_updated
|
pc.last_updated
|
||||||
@@ -885,6 +891,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
|||||||
longitude: parseFloat(d.longitude)
|
longitude: parseFloat(d.longitude)
|
||||||
} : null,
|
} : null,
|
||||||
platform: d.platform,
|
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),
|
product_count: parseInt(d.product_count || '0', 10),
|
||||||
in_stock_count: parseInt(d.in_stock_count || '0', 10),
|
in_stock_count: parseInt(d.in_stock_count || '0', 10),
|
||||||
last_updated: d.last_updated,
|
last_updated: d.last_updated,
|
||||||
@@ -935,6 +947,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
|||||||
d.longitude,
|
d.longitude,
|
||||||
d.menu_type as platform,
|
d.menu_type as platform,
|
||||||
d.menu_url,
|
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.product_count, 0) as product_count,
|
||||||
COALESCE(pc.in_stock_count, 0) as in_stock_count,
|
COALESCE(pc.in_stock_count, 0) as in_stock_count,
|
||||||
pc.last_updated
|
pc.last_updated
|
||||||
@@ -980,6 +998,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
|
|||||||
longitude: parseFloat(d.longitude)
|
longitude: parseFloat(d.longitude)
|
||||||
} : null,
|
} : null,
|
||||||
platform: d.platform,
|
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),
|
product_count: parseInt(d.product_count || '0', 10),
|
||||||
in_stock_count: parseInt(d.in_stock_count || '0', 10),
|
in_stock_count: parseInt(d.in_stock_count || '0', 10),
|
||||||
last_updated: d.last_updated,
|
last_updated: d.last_updated,
|
||||||
|
|||||||
@@ -9,14 +9,36 @@ const router = Router();
|
|||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
router.use(requireRole('admin', 'superadmin'));
|
router.use(requireRole('admin', 'superadmin'));
|
||||||
|
|
||||||
// Get all users
|
// Get all users with search and filter
|
||||||
router.get('/', async (req: AuthRequest, res) => {
|
router.get('/', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const { search, domain } = req.query;
|
||||||
SELECT id, email, role, created_at, updated_at
|
|
||||||
|
let query = `
|
||||||
|
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||||
FROM users
|
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 });
|
res.json({ users: result.rows });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching users:', error);
|
console.error('Error fetching users:', error);
|
||||||
@@ -29,7 +51,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const result = await pool.query(`
|
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
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, [id]);
|
`, [id]);
|
||||||
@@ -48,7 +70,7 @@ router.get('/:id', async (req: AuthRequest, res) => {
|
|||||||
// Create user
|
// Create user
|
||||||
router.post('/', async (req: AuthRequest, res) => {
|
router.post('/', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, password, role } = req.body;
|
const { email, password, role, first_name, last_name, phone, domain } = req.body;
|
||||||
|
|
||||||
if (!email || !password) {
|
if (!email || !password) {
|
||||||
return res.status(400).json({ error: 'Email and password are required' });
|
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' });
|
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
|
// Check if email already exists
|
||||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]);
|
||||||
if (existing.rows.length > 0) {
|
if (existing.rows.length > 0) {
|
||||||
@@ -70,10 +98,10 @@ router.post('/', async (req: AuthRequest, res) => {
|
|||||||
const passwordHash = await bcrypt.hash(password, 10);
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
INSERT INTO users (email, password_hash, role)
|
INSERT INTO users (email, password_hash, role, first_name, last_name, phone, domain)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING id, email, role, created_at, updated_at
|
RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
||||||
`, [email, passwordHash, role || 'viewer']);
|
`, [email, passwordHash, role || 'viewer', first_name || null, last_name || null, phone || null, domain || 'cannaiq.co']);
|
||||||
|
|
||||||
res.status(201).json({ user: result.rows[0] });
|
res.status(201).json({ user: result.rows[0] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -86,7 +114,7 @@ router.post('/', async (req: AuthRequest, res) => {
|
|||||||
router.put('/:id', async (req: AuthRequest, res) => {
|
router.put('/:id', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
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
|
// Check if user exists
|
||||||
const existing = await pool.query('SELECT id FROM users WHERE id = $1', [id]);
|
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' });
|
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
|
// Prevent non-superadmin from modifying superadmin users
|
||||||
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
|
const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]);
|
||||||
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
|
if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') {
|
||||||
@@ -132,6 +166,27 @@ router.put('/:id', async (req: AuthRequest, res) => {
|
|||||||
values.push(role);
|
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) {
|
if (updates.length === 0) {
|
||||||
return res.status(400).json({ error: 'No fields to update' });
|
return res.status(400).json({ error: 'No fields to update' });
|
||||||
}
|
}
|
||||||
@@ -143,7 +198,7 @@ router.put('/:id', async (req: AuthRequest, res) => {
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET ${updates.join(', ')}
|
SET ${updates.join(', ')}
|
||||||
WHERE id = $${paramIndex}
|
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);
|
`, values);
|
||||||
|
|
||||||
res.json({ user: result.rows[0] });
|
res.json({ user: result.rows[0] });
|
||||||
|
|||||||
18
findadispo/backend/.env.example
Normal file
18
findadispo/backend/.env.example
Normal 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
139
findadispo/backend/auth.py
Normal 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
|
||||||
31
findadispo/backend/config.py
Normal file
31
findadispo/backend/config.py
Normal 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()
|
||||||
25
findadispo/backend/database.py
Normal file
25
findadispo/backend/database.py
Normal 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)
|
||||||
77
findadispo/backend/models.py
Normal file
77
findadispo/backend/models.py
Normal 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())
|
||||||
11
findadispo/backend/requirements.txt
Normal file
11
findadispo/backend/requirements.txt
Normal 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
|
||||||
1
findadispo/backend/routes/__init__.py
Normal file
1
findadispo/backend/routes/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Routes package
|
||||||
234
findadispo/backend/routes/alerts_routes.py
Normal file
234
findadispo/backend/routes/alerts_routes.py
Normal 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]),
|
||||||
|
}
|
||||||
108
findadispo/backend/routes/auth_routes.py
Normal file
108
findadispo/backend/routes/auth_routes.py
Normal 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"}
|
||||||
49
findadispo/backend/routes/contact_routes.py
Normal file
49
findadispo/backend/routes/contact_routes.py
Normal 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
|
||||||
|
}
|
||||||
164
findadispo/backend/routes/dispensary_routes.py
Normal file
164
findadispo/backend/routes/dispensary_routes.py
Normal 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", [])
|
||||||
201
findadispo/backend/routes/search_routes.py
Normal file
201
findadispo/backend/routes/search_routes.py
Normal 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"}
|
||||||
81
findadispo/backend/server.py
Normal file
81
findadispo/backend/server.py
Normal 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
|
||||||
|
)
|
||||||
14
findadispo/frontend/.env.example
Normal file
14
findadispo/frontend/.env.example
Normal 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
|
||||||
52
findadispo/frontend/Dockerfile
Normal file
52
findadispo/frontend/Dockerfile
Normal 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;"]
|
||||||
62
findadispo/frontend/package.json
Normal file
62
findadispo/frontend/package.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
findadispo/frontend/postcss.config.js
Normal file
6
findadispo/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
17
findadispo/frontend/public/index.html
Normal file
17
findadispo/frontend/public/index.html
Normal 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>
|
||||||
29
findadispo/frontend/public/manifest.json
Normal file
29
findadispo/frontend/public/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
113
findadispo/frontend/public/service-worker.js
Normal file
113
findadispo/frontend/public/service-worker.js
Normal 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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
130
findadispo/frontend/src/App.js
Normal file
130
findadispo/frontend/src/App.js
Normal 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;
|
||||||
290
findadispo/frontend/src/api/client.js
Normal file
290
findadispo/frontend/src/api/client.js
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
119
findadispo/frontend/src/components/findadispo/Footer.jsx
Normal file
119
findadispo/frontend/src/components/findadispo/Footer.jsx
Normal 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;
|
||||||
128
findadispo/frontend/src/components/findadispo/Header.jsx
Normal file
128
findadispo/frontend/src/components/findadispo/Header.jsx
Normal 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;
|
||||||
35
findadispo/frontend/src/components/ui/badge.jsx
Normal file
35
findadispo/frontend/src/components/ui/badge.jsx
Normal 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 };
|
||||||
46
findadispo/frontend/src/components/ui/button.jsx
Normal file
46
findadispo/frontend/src/components/ui/button.jsx
Normal 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 };
|
||||||
60
findadispo/frontend/src/components/ui/card.jsx
Normal file
60
findadispo/frontend/src/components/ui/card.jsx
Normal 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 };
|
||||||
22
findadispo/frontend/src/components/ui/checkbox.jsx
Normal file
22
findadispo/frontend/src/components/ui/checkbox.jsx
Normal 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 };
|
||||||
19
findadispo/frontend/src/components/ui/input.jsx
Normal file
19
findadispo/frontend/src/components/ui/input.jsx
Normal 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 };
|
||||||
19
findadispo/frontend/src/components/ui/label.jsx
Normal file
19
findadispo/frontend/src/components/ui/label.jsx
Normal 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 };
|
||||||
22
findadispo/frontend/src/components/ui/separator.jsx
Normal file
22
findadispo/frontend/src/components/ui/separator.jsx
Normal 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 };
|
||||||
22
findadispo/frontend/src/components/ui/slider.jsx
Normal file
22
findadispo/frontend/src/components/ui/slider.jsx
Normal 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 };
|
||||||
23
findadispo/frontend/src/components/ui/switch.jsx
Normal file
23
findadispo/frontend/src/components/ui/switch.jsx
Normal 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 };
|
||||||
18
findadispo/frontend/src/components/ui/textarea.jsx
Normal file
18
findadispo/frontend/src/components/ui/textarea.jsx
Normal 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 };
|
||||||
56
findadispo/frontend/src/index.css
Normal file
56
findadispo/frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
24
findadispo/frontend/src/index.js
Normal file
24
findadispo/frontend/src/index.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
89
findadispo/frontend/src/lib/utils.js
Normal file
89
findadispo/frontend/src/lib/utils.js
Normal 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 || ''
|
||||||
|
};
|
||||||
234
findadispo/frontend/src/mockData.js
Normal file
234
findadispo/frontend/src/mockData.js
Normal 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;
|
||||||
|
};
|
||||||
133
findadispo/frontend/src/pages/findadispo/About.jsx
Normal file
133
findadispo/frontend/src/pages/findadispo/About.jsx
Normal 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;
|
||||||
197
findadispo/frontend/src/pages/findadispo/Alerts.jsx
Normal file
197
findadispo/frontend/src/pages/findadispo/Alerts.jsx
Normal 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;
|
||||||
213
findadispo/frontend/src/pages/findadispo/Contact.jsx
Normal file
213
findadispo/frontend/src/pages/findadispo/Contact.jsx
Normal 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;
|
||||||
104
findadispo/frontend/src/pages/findadispo/Dashboard.jsx
Normal file
104
findadispo/frontend/src/pages/findadispo/Dashboard.jsx
Normal 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;
|
||||||
177
findadispo/frontend/src/pages/findadispo/DashboardHome.jsx
Normal file
177
findadispo/frontend/src/pages/findadispo/DashboardHome.jsx
Normal 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;
|
||||||
228
findadispo/frontend/src/pages/findadispo/DispensaryDetail.jsx
Normal file
228
findadispo/frontend/src/pages/findadispo/DispensaryDetail.jsx
Normal 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;
|
||||||
301
findadispo/frontend/src/pages/findadispo/Home.jsx
Normal file
301
findadispo/frontend/src/pages/findadispo/Home.jsx
Normal 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;
|
||||||
194
findadispo/frontend/src/pages/findadispo/Login.jsx
Normal file
194
findadispo/frontend/src/pages/findadispo/Login.jsx
Normal 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;
|
||||||
246
findadispo/frontend/src/pages/findadispo/Profile.jsx
Normal file
246
findadispo/frontend/src/pages/findadispo/Profile.jsx
Normal 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;
|
||||||
105
findadispo/frontend/src/pages/findadispo/SavedSearches.jsx
Normal file
105
findadispo/frontend/src/pages/findadispo/SavedSearches.jsx
Normal 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;
|
||||||
306
findadispo/frontend/src/pages/findadispo/Signup.jsx
Normal file
306
findadispo/frontend/src/pages/findadispo/Signup.jsx
Normal 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;
|
||||||
321
findadispo/frontend/src/pages/findadispo/StoreLocator.jsx
Normal file
321
findadispo/frontend/src/pages/findadispo/StoreLocator.jsx
Normal 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;
|
||||||
94
findadispo/frontend/tailwind.config.js
Normal file
94
findadispo/frontend/tailwind.config.js
Normal 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")],
|
||||||
|
}
|
||||||
9
findagram/backend/.dockerignore
Normal file
9
findagram/backend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
13
findagram/backend/.env.example
Normal file
13
findagram/backend/.env.example
Normal 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"]
|
||||||
25
findagram/backend/Dockerfile
Normal file
25
findagram/backend/Dockerfile
Normal 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"]
|
||||||
1
findagram/backend/app/__init__.py
Normal file
1
findagram/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Find a Gram Backend
|
||||||
29
findagram/backend/app/config.py
Normal file
29
findagram/backend/app/config.py
Normal 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()
|
||||||
30
findagram/backend/app/database.py
Normal file
30
findagram/backend/app/database.py
Normal 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()
|
||||||
4
findagram/backend/app/models/__init__.py
Normal file
4
findagram/backend/app/models/__init__.py
Normal 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"]
|
||||||
120
findagram/backend/app/models/product.py
Normal file
120
findagram/backend/app/models/product.py
Normal 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")
|
||||||
26
findagram/backend/app/models/user.py
Normal file
26
findagram/backend/app/models/user.py
Normal 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())
|
||||||
11
findagram/backend/app/routes/__init__.py
Normal file
11
findagram/backend/app/routes/__init__.py
Normal 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"])
|
||||||
77
findagram/backend/app/routes/auth.py
Normal file
77
findagram/backend/app/routes/auth.py
Normal 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)
|
||||||
32
findagram/backend/app/routes/brands.py
Normal file
32
findagram/backend/app/routes/brands.py
Normal 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)
|
||||||
32
findagram/backend/app/routes/categories.py
Normal file
32
findagram/backend/app/routes/categories.py
Normal 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)
|
||||||
135
findagram/backend/app/routes/products.py
Normal file
135
findagram/backend/app/routes/products.py
Normal 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)
|
||||||
61
findagram/backend/app/routes/users.py
Normal file
61
findagram/backend/app/routes/users.py
Normal 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)
|
||||||
20
findagram/backend/app/schemas/__init__.py
Normal file
20
findagram/backend/app/schemas/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
96
findagram/backend/app/schemas/product.py
Normal file
96
findagram/backend/app/schemas/product.py
Normal 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
|
||||||
49
findagram/backend/app/schemas/user.py
Normal file
49
findagram/backend/app/schemas/user.py
Normal 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
44
findagram/backend/main.py
Normal 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)
|
||||||
13
findagram/backend/requirements.txt
Normal file
13
findagram/backend/requirements.txt
Normal 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
|
||||||
7
findagram/frontend/.dockerignore
Normal file
7
findagram/frontend/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
3
findagram/frontend/.env.development
Normal file
3
findagram/frontend/.env.development
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Development environment
|
||||||
|
# API URL for local development
|
||||||
|
REACT_APP_API_URL=http://localhost:3010
|
||||||
7
findagram/frontend/.env.example
Normal file
7
findagram/frontend/.env.example
Normal 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
|
||||||
3
findagram/frontend/.env.production
Normal file
3
findagram/frontend/.env.production
Normal 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
23
findagram/frontend/.gitignore
vendored
Normal 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*
|
||||||
52
findagram/frontend/Dockerfile
Normal file
52
findagram/frontend/Dockerfile
Normal 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
17809
findagram/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
findagram/frontend/package.json
Normal file
52
findagram/frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
findagram/frontend/postcss.config.js
Normal file
6
findagram/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
20
findagram/frontend/public/index.html
Normal file
20
findagram/frontend/public/index.html
Normal 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>
|
||||||
28
findagram/frontend/public/manifest.json
Normal file
28
findagram/frontend/public/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
112
findagram/frontend/public/service-worker.js
Normal file
112
findagram/frontend/public/service-worker.js
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
86
findagram/frontend/src/App.js
Normal file
86
findagram/frontend/src/App.js
Normal 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;
|
||||||
417
findagram/frontend/src/api/client.js
Normal file
417
findagram/frontend/src/api/client.js
Normal 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;
|
||||||
174
findagram/frontend/src/components/findagram/Footer.jsx
Normal file
174
findagram/frontend/src/components/findagram/Footer.jsx
Normal 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">
|
||||||
|
© {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;
|
||||||
272
findagram/frontend/src/components/findagram/Header.jsx
Normal file
272
findagram/frontend/src/components/findagram/Header.jsx
Normal 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;
|
||||||
161
findagram/frontend/src/components/findagram/ProductCard.jsx
Normal file
161
findagram/frontend/src/components/findagram/ProductCard.jsx
Normal 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;
|
||||||
38
findagram/frontend/src/components/ui/avatar.jsx
Normal file
38
findagram/frontend/src/components/ui/avatar.jsx
Normal 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 };
|
||||||
37
findagram/frontend/src/components/ui/badge.jsx
Normal file
37
findagram/frontend/src/components/ui/badge.jsx
Normal 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 };
|
||||||
49
findagram/frontend/src/components/ui/button.jsx
Normal file
49
findagram/frontend/src/components/ui/button.jsx
Normal 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
Reference in New Issue
Block a user