From a0f8d3911c75063c1e89875e1353c6cf4e1efe01 Mon Sep 17 00:00:00 2001 From: Kelly Date: Fri, 5 Dec 2025 16:10:15 -0700 Subject: [PATCH] feat: Add Findagram and FindADispo consumer frontends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 229 + backend/migrations/035_multi_domain_users.sql | 172 + .../036_findadispo_dispensary_fields.sql | 47 + backend/package.json | 2 +- backend/src/dutchie-az/routes/index.ts | 187 + backend/src/index.ts | 7 +- backend/src/middleware/trustedDomains.ts | 107 + backend/src/routes/public-api.ts | 24 + backend/src/routes/users.ts | 81 +- findadispo/backend/.env.example | 18 + findadispo/backend/auth.py | 139 + findadispo/backend/config.py | 31 + findadispo/backend/database.py | 25 + findadispo/backend/models.py | 77 + findadispo/backend/requirements.txt | 11 + findadispo/backend/routes/__init__.py | 1 + findadispo/backend/routes/alerts_routes.py | 234 + findadispo/backend/routes/auth_routes.py | 108 + findadispo/backend/routes/contact_routes.py | 49 + .../backend/routes/dispensary_routes.py | 164 + findadispo/backend/routes/search_routes.py | 201 + findadispo/backend/server.py | 81 + findadispo/frontend/.env.example | 14 + findadispo/frontend/Dockerfile | 52 + findadispo/frontend/package.json | 62 + findadispo/frontend/postcss.config.js | 6 + findadispo/frontend/public/index.html | 17 + findadispo/frontend/public/manifest.json | 29 + findadispo/frontend/public/service-worker.js | 113 + findadispo/frontend/src/App.js | 130 + findadispo/frontend/src/api/client.js | 290 + .../src/components/findadispo/Footer.jsx | 119 + .../src/components/findadispo/Header.jsx | 128 + .../frontend/src/components/ui/badge.jsx | 35 + .../frontend/src/components/ui/button.jsx | 46 + .../frontend/src/components/ui/card.jsx | 60 + .../frontend/src/components/ui/checkbox.jsx | 22 + .../frontend/src/components/ui/input.jsx | 19 + .../frontend/src/components/ui/label.jsx | 19 + .../frontend/src/components/ui/separator.jsx | 22 + .../frontend/src/components/ui/slider.jsx | 22 + .../frontend/src/components/ui/switch.jsx | 23 + .../frontend/src/components/ui/textarea.jsx | 18 + findadispo/frontend/src/index.css | 56 + findadispo/frontend/src/index.js | 24 + findadispo/frontend/src/lib/utils.js | 89 + findadispo/frontend/src/mockData.js | 234 + .../frontend/src/pages/findadispo/About.jsx | 133 + .../frontend/src/pages/findadispo/Alerts.jsx | 197 + .../frontend/src/pages/findadispo/Contact.jsx | 213 + .../src/pages/findadispo/Dashboard.jsx | 104 + .../src/pages/findadispo/DashboardHome.jsx | 177 + .../src/pages/findadispo/DispensaryDetail.jsx | 228 + .../frontend/src/pages/findadispo/Home.jsx | 301 + .../frontend/src/pages/findadispo/Login.jsx | 194 + .../frontend/src/pages/findadispo/Profile.jsx | 246 + .../src/pages/findadispo/SavedSearches.jsx | 105 + .../frontend/src/pages/findadispo/Signup.jsx | 306 + .../src/pages/findadispo/StoreLocator.jsx | 321 + findadispo/frontend/tailwind.config.js | 94 + findagram/backend/.dockerignore | 9 + findagram/backend/.env.example | 13 + findagram/backend/Dockerfile | 25 + findagram/backend/app/__init__.py | 1 + findagram/backend/app/config.py | 29 + findagram/backend/app/database.py | 30 + findagram/backend/app/models/__init__.py | 4 + findagram/backend/app/models/product.py | 120 + findagram/backend/app/models/user.py | 26 + findagram/backend/app/routes/__init__.py | 11 + findagram/backend/app/routes/auth.py | 77 + findagram/backend/app/routes/brands.py | 32 + findagram/backend/app/routes/categories.py | 32 + findagram/backend/app/routes/products.py | 135 + findagram/backend/app/routes/users.py | 61 + findagram/backend/app/schemas/__init__.py | 20 + findagram/backend/app/schemas/product.py | 96 + findagram/backend/app/schemas/user.py | 49 + findagram/backend/main.py | 44 + findagram/backend/requirements.txt | 13 + findagram/frontend/.dockerignore | 7 + findagram/frontend/.env.development | 3 + findagram/frontend/.env.example | 7 + findagram/frontend/.env.production | 3 + findagram/frontend/.gitignore | 23 + findagram/frontend/Dockerfile | 52 + findagram/frontend/package-lock.json | 17809 +++++++ findagram/frontend/package.json | 52 + findagram/frontend/postcss.config.js | 6 + findagram/frontend/public/index.html | 20 + findagram/frontend/public/manifest.json | 28 + findagram/frontend/public/service-worker.js | 112 + findagram/frontend/src/App.js | 86 + findagram/frontend/src/api/client.js | 417 + .../src/components/findagram/Footer.jsx | 174 + .../src/components/findagram/Header.jsx | 272 + .../src/components/findagram/ProductCard.jsx | 161 + .../frontend/src/components/ui/avatar.jsx | 38 + .../frontend/src/components/ui/badge.jsx | 37 + .../frontend/src/components/ui/button.jsx | 49 + findagram/frontend/src/components/ui/card.jsx | 60 + .../frontend/src/components/ui/checkbox.jsx | 24 + .../frontend/src/components/ui/dialog.jsx | 100 + .../src/components/ui/dropdown-menu.jsx | 175 + .../frontend/src/components/ui/input.jsx | 19 + .../frontend/src/components/ui/select.jsx | 143 + .../frontend/src/components/ui/slider.jsx | 22 + findagram/frontend/src/components/ui/tabs.jsx | 43 + findagram/frontend/src/index.css | 74 + findagram/frontend/src/index.js | 11 + findagram/frontend/src/lib/utils.js | 6 + findagram/frontend/src/mockData.js | 343 + .../frontend/src/pages/findagram/About.jsx | 200 + .../frontend/src/pages/findagram/Alerts.jsx | 207 + .../src/pages/findagram/BrandDetail.jsx | 205 + .../frontend/src/pages/findagram/Brands.jsx | 188 + .../src/pages/findagram/Categories.jsx | 137 + .../src/pages/findagram/CategoryDetail.jsx | 259 + .../frontend/src/pages/findagram/Contact.jsx | 220 + .../src/pages/findagram/Dashboard.jsx | 300 + .../frontend/src/pages/findagram/Deals.jsx | 262 + .../src/pages/findagram/Favorites.jsx | 85 + .../frontend/src/pages/findagram/Home.jsx | 444 + .../frontend/src/pages/findagram/Login.jsx | 193 + .../src/pages/findagram/ProductDetail.jsx | 584 + .../frontend/src/pages/findagram/Products.jsx | 677 + .../frontend/src/pages/findagram/Profile.jsx | 288 + .../src/pages/findagram/SavedSearches.jsx | 123 + .../frontend/src/pages/findagram/Signup.jsx | 262 + findagram/frontend/tailwind.config.js | 104 + frontend/.env.production | 1 + frontend/.vite/deps/_metadata.json | 61 + frontend/.vite/deps/chunk-DDNM7ENY.js | 1938 + frontend/.vite/deps/chunk-DDNM7ENY.js.map | 7 + frontend/.vite/deps/chunk-DI3PBQUW.js | 188 + frontend/.vite/deps/chunk-DI3PBQUW.js.map | 7 + frontend/.vite/deps/chunk-L2Q2RVSC.js | 21627 +++++++++ frontend/.vite/deps/chunk-L2Q2RVSC.js.map | 7 + frontend/.vite/deps/lucide-react.js | 40205 ++++++++++++++++ frontend/.vite/deps/lucide-react.js.map | 7 + frontend/.vite/deps/package.json | 3 + frontend/.vite/deps/react-dom_client.js | 38 + frontend/.vite/deps/react-dom_client.js.map | 7 + frontend/.vite/deps/react-router-dom.js | 6053 +++ frontend/.vite/deps/react-router-dom.js.map | 7 + frontend/.vite/deps/react.js | 5 + frontend/.vite/deps/react.js.map | 7 + frontend/.vite/deps/react_jsx-dev-runtime.js | 911 + .../.vite/deps/react_jsx-dev-runtime.js.map | 7 + frontend/.vite/deps/recharts.js | 35936 ++++++++++++++ frontend/.vite/deps/recharts.js.map | 7 + frontend/.vite/deps/zustand.js | 91 + frontend/.vite/deps/zustand.js.map | 7 + frontend/Dockerfile | 2 +- frontend/dist/assets/index-Bem3N9Ya.css | 1 + frontend/dist/assets/index-Db0w17d2.css | 1 - frontend/dist/assets/index-Sv2qKWG5.js | 426 + frontend/dist/assets/index-floXOwYs.js | 421 - frontend/dist/index.html | 6 +- .../dist/wordpress/crawlsy-menus-v1.5.2.zip | Bin 0 -> 13671 bytes frontend/index.html | 2 +- frontend/package.json | 2 +- .../public/wordpress/crawlsy-menus-v1.5.2.zip | Bin 0 -> 13671 bytes frontend/src/components/Layout.tsx | 7 +- frontend/src/lib/api.ts | 15 +- frontend/src/pages/DispensaryDetail.tsx | 2 +- frontend/src/pages/DutchieAZSchedule.tsx | 6 +- frontend/src/pages/DutchieAZStores.tsx | 4 +- frontend/src/pages/ProductDetail.tsx | 2 +- frontend/src/pages/Products.tsx | 2 +- frontend/src/pages/ScraperSchedule.tsx | 2 +- frontend/src/pages/StoreDetail.tsx | 2 +- frontend/src/pages/Stores.tsx | 4 +- frontend/src/pages/Users.tsx | 428 +- frontend/src/pages/WholesaleAnalytics.tsx | 2 +- k8s/findadispo-frontend.yaml | 41 + ...{frontend.yaml => findagram-frontend.yaml} | 14 +- k8s/ingress.yaml | 105 +- wordpress-plugin/crawlsy-menus.php | 6 +- 179 files changed, 140234 insertions(+), 600 deletions(-) create mode 100644 backend/migrations/035_multi_domain_users.sql create mode 100644 backend/migrations/036_findadispo_dispensary_fields.sql create mode 100644 backend/src/middleware/trustedDomains.ts create mode 100644 findadispo/backend/.env.example create mode 100644 findadispo/backend/auth.py create mode 100644 findadispo/backend/config.py create mode 100644 findadispo/backend/database.py create mode 100644 findadispo/backend/models.py create mode 100644 findadispo/backend/requirements.txt create mode 100644 findadispo/backend/routes/__init__.py create mode 100644 findadispo/backend/routes/alerts_routes.py create mode 100644 findadispo/backend/routes/auth_routes.py create mode 100644 findadispo/backend/routes/contact_routes.py create mode 100644 findadispo/backend/routes/dispensary_routes.py create mode 100644 findadispo/backend/routes/search_routes.py create mode 100644 findadispo/backend/server.py create mode 100644 findadispo/frontend/.env.example create mode 100644 findadispo/frontend/Dockerfile create mode 100644 findadispo/frontend/package.json create mode 100644 findadispo/frontend/postcss.config.js create mode 100644 findadispo/frontend/public/index.html create mode 100644 findadispo/frontend/public/manifest.json create mode 100644 findadispo/frontend/public/service-worker.js create mode 100644 findadispo/frontend/src/App.js create mode 100644 findadispo/frontend/src/api/client.js create mode 100644 findadispo/frontend/src/components/findadispo/Footer.jsx create mode 100644 findadispo/frontend/src/components/findadispo/Header.jsx create mode 100644 findadispo/frontend/src/components/ui/badge.jsx create mode 100644 findadispo/frontend/src/components/ui/button.jsx create mode 100644 findadispo/frontend/src/components/ui/card.jsx create mode 100644 findadispo/frontend/src/components/ui/checkbox.jsx create mode 100644 findadispo/frontend/src/components/ui/input.jsx create mode 100644 findadispo/frontend/src/components/ui/label.jsx create mode 100644 findadispo/frontend/src/components/ui/separator.jsx create mode 100644 findadispo/frontend/src/components/ui/slider.jsx create mode 100644 findadispo/frontend/src/components/ui/switch.jsx create mode 100644 findadispo/frontend/src/components/ui/textarea.jsx create mode 100644 findadispo/frontend/src/index.css create mode 100644 findadispo/frontend/src/index.js create mode 100644 findadispo/frontend/src/lib/utils.js create mode 100644 findadispo/frontend/src/mockData.js create mode 100644 findadispo/frontend/src/pages/findadispo/About.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/Alerts.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/Contact.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/Dashboard.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/DashboardHome.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/DispensaryDetail.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/Home.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/Login.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/Profile.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/SavedSearches.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/Signup.jsx create mode 100644 findadispo/frontend/src/pages/findadispo/StoreLocator.jsx create mode 100644 findadispo/frontend/tailwind.config.js create mode 100644 findagram/backend/.dockerignore create mode 100644 findagram/backend/.env.example create mode 100644 findagram/backend/Dockerfile create mode 100644 findagram/backend/app/__init__.py create mode 100644 findagram/backend/app/config.py create mode 100644 findagram/backend/app/database.py create mode 100644 findagram/backend/app/models/__init__.py create mode 100644 findagram/backend/app/models/product.py create mode 100644 findagram/backend/app/models/user.py create mode 100644 findagram/backend/app/routes/__init__.py create mode 100644 findagram/backend/app/routes/auth.py create mode 100644 findagram/backend/app/routes/brands.py create mode 100644 findagram/backend/app/routes/categories.py create mode 100644 findagram/backend/app/routes/products.py create mode 100644 findagram/backend/app/routes/users.py create mode 100644 findagram/backend/app/schemas/__init__.py create mode 100644 findagram/backend/app/schemas/product.py create mode 100644 findagram/backend/app/schemas/user.py create mode 100644 findagram/backend/main.py create mode 100644 findagram/backend/requirements.txt create mode 100644 findagram/frontend/.dockerignore create mode 100644 findagram/frontend/.env.development create mode 100644 findagram/frontend/.env.example create mode 100644 findagram/frontend/.env.production create mode 100644 findagram/frontend/.gitignore create mode 100644 findagram/frontend/Dockerfile create mode 100644 findagram/frontend/package-lock.json create mode 100644 findagram/frontend/package.json create mode 100644 findagram/frontend/postcss.config.js create mode 100644 findagram/frontend/public/index.html create mode 100644 findagram/frontend/public/manifest.json create mode 100644 findagram/frontend/public/service-worker.js create mode 100644 findagram/frontend/src/App.js create mode 100644 findagram/frontend/src/api/client.js create mode 100644 findagram/frontend/src/components/findagram/Footer.jsx create mode 100644 findagram/frontend/src/components/findagram/Header.jsx create mode 100644 findagram/frontend/src/components/findagram/ProductCard.jsx create mode 100644 findagram/frontend/src/components/ui/avatar.jsx create mode 100644 findagram/frontend/src/components/ui/badge.jsx create mode 100644 findagram/frontend/src/components/ui/button.jsx create mode 100644 findagram/frontend/src/components/ui/card.jsx create mode 100644 findagram/frontend/src/components/ui/checkbox.jsx create mode 100644 findagram/frontend/src/components/ui/dialog.jsx create mode 100644 findagram/frontend/src/components/ui/dropdown-menu.jsx create mode 100644 findagram/frontend/src/components/ui/input.jsx create mode 100644 findagram/frontend/src/components/ui/select.jsx create mode 100644 findagram/frontend/src/components/ui/slider.jsx create mode 100644 findagram/frontend/src/components/ui/tabs.jsx create mode 100644 findagram/frontend/src/index.css create mode 100644 findagram/frontend/src/index.js create mode 100644 findagram/frontend/src/lib/utils.js create mode 100644 findagram/frontend/src/mockData.js create mode 100644 findagram/frontend/src/pages/findagram/About.jsx create mode 100644 findagram/frontend/src/pages/findagram/Alerts.jsx create mode 100644 findagram/frontend/src/pages/findagram/BrandDetail.jsx create mode 100644 findagram/frontend/src/pages/findagram/Brands.jsx create mode 100644 findagram/frontend/src/pages/findagram/Categories.jsx create mode 100644 findagram/frontend/src/pages/findagram/CategoryDetail.jsx create mode 100644 findagram/frontend/src/pages/findagram/Contact.jsx create mode 100644 findagram/frontend/src/pages/findagram/Dashboard.jsx create mode 100644 findagram/frontend/src/pages/findagram/Deals.jsx create mode 100644 findagram/frontend/src/pages/findagram/Favorites.jsx create mode 100644 findagram/frontend/src/pages/findagram/Home.jsx create mode 100644 findagram/frontend/src/pages/findagram/Login.jsx create mode 100644 findagram/frontend/src/pages/findagram/ProductDetail.jsx create mode 100644 findagram/frontend/src/pages/findagram/Products.jsx create mode 100644 findagram/frontend/src/pages/findagram/Profile.jsx create mode 100644 findagram/frontend/src/pages/findagram/SavedSearches.jsx create mode 100644 findagram/frontend/src/pages/findagram/Signup.jsx create mode 100644 findagram/frontend/tailwind.config.js create mode 100644 frontend/.env.production create mode 100644 frontend/.vite/deps/_metadata.json create mode 100644 frontend/.vite/deps/chunk-DDNM7ENY.js create mode 100644 frontend/.vite/deps/chunk-DDNM7ENY.js.map create mode 100644 frontend/.vite/deps/chunk-DI3PBQUW.js create mode 100644 frontend/.vite/deps/chunk-DI3PBQUW.js.map create mode 100644 frontend/.vite/deps/chunk-L2Q2RVSC.js create mode 100644 frontend/.vite/deps/chunk-L2Q2RVSC.js.map create mode 100644 frontend/.vite/deps/lucide-react.js create mode 100644 frontend/.vite/deps/lucide-react.js.map create mode 100644 frontend/.vite/deps/package.json create mode 100644 frontend/.vite/deps/react-dom_client.js create mode 100644 frontend/.vite/deps/react-dom_client.js.map create mode 100644 frontend/.vite/deps/react-router-dom.js create mode 100644 frontend/.vite/deps/react-router-dom.js.map create mode 100644 frontend/.vite/deps/react.js create mode 100644 frontend/.vite/deps/react.js.map create mode 100644 frontend/.vite/deps/react_jsx-dev-runtime.js create mode 100644 frontend/.vite/deps/react_jsx-dev-runtime.js.map create mode 100644 frontend/.vite/deps/recharts.js create mode 100644 frontend/.vite/deps/recharts.js.map create mode 100644 frontend/.vite/deps/zustand.js create mode 100644 frontend/.vite/deps/zustand.js.map create mode 100644 frontend/dist/assets/index-Bem3N9Ya.css delete mode 100644 frontend/dist/assets/index-Db0w17d2.css create mode 100644 frontend/dist/assets/index-Sv2qKWG5.js delete mode 100644 frontend/dist/assets/index-floXOwYs.js create mode 100644 frontend/dist/wordpress/crawlsy-menus-v1.5.2.zip create mode 100644 frontend/public/wordpress/crawlsy-menus-v1.5.2.zip create mode 100644 k8s/findadispo-frontend.yaml rename k8s/{frontend.yaml => findagram-frontend.yaml} (70%) diff --git a/CLAUDE.md b/CLAUDE.md index a57c81bb..e3013911 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,136 @@ ## Claude Guidelines for this Project +### Multi-Site Architecture (CRITICAL) + +This project has **5 working locations** - always clarify which one before making changes: + +| Folder | Domain | Type | Purpose | +|--------|--------|------|---------| +| `backend/` | (shared) | Express API | Single backend serving all frontends | +| `frontend/` | dispos.crawlsy.com | React SPA (Vite) | Legacy admin dashboard (internal use) | +| `cannaiq/` | cannaiq.co | React SPA + PWA | NEW admin dashboard / B2B analytics | +| `findadispo/` | findadispo.com | React SPA + PWA | Consumer dispensary finder | +| `findagram/` | findagram.co | React SPA + PWA | Consumer delivery marketplace | + +**IMPORTANT: `frontend/` vs `cannaiq/` confusion:** +- `frontend/` = OLD/legacy dashboard design, deployed to `dispos.crawlsy.com` (internal admin) +- `cannaiq/` = NEW dashboard design, deployed to `cannaiq.co` (customer-facing B2B) +- These are DIFFERENT codebases - do NOT confuse them! + +**Before any frontend work, ASK: "Which site? cannaiq, findadispo, findagram, or legacy (frontend/)?"** + +All four frontends share: +- Same backend API (port 3010) +- Same PostgreSQL database +- Same Kubernetes deployment for backend + +Each frontend has: +- Its own folder, package.json, Dockerfile +- Its own domain and branding +- Its own PWA manifest and service worker (cannaiq, findadispo, findagram) +- Separate Docker containers in production + +--- + +### Multi-Domain Hosting Architecture + +All three frontends are served from the **same IP** using **host-based routing**: + +**Kubernetes Ingress (Production):** +```yaml +# Each domain routes to its own frontend service +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: multi-site-ingress +spec: + rules: + - host: cannaiq.co + http: + paths: + - path: / + backend: + service: + name: cannaiq-frontend + port: 80 + - path: /api + backend: + service: + name: scraper # shared backend + port: 3010 + - host: findadispo.com + http: + paths: + - path: / + backend: + service: + name: findadispo-frontend + port: 80 + - path: /api + backend: + service: + name: scraper + port: 3010 + - host: findagram.co + http: + paths: + - path: / + backend: + service: + name: findagram-frontend + port: 80 + - path: /api + backend: + service: + name: scraper + port: 3010 +``` + +**Key Points:** +- DNS A records for all 3 domains point to same IP +- Ingress controller routes based on `Host` header +- Each frontend is a separate Docker container (nginx serving static files) +- All frontends share the same backend API at `/api/*` +- SSL/TLS handled at ingress level (cert-manager) + +--- + +### PWA Setup Requirements + +Each frontend is a **Progressive Web App (PWA)**. Required files in each `public/` folder: + +1. **manifest.json** - App metadata, icons, theme colors +2. **service-worker.js** - Offline caching, background sync +3. **Icons** - 192x192 and 512x512 PNG icons + +**Vite PWA Plugin Setup** (in each frontend's vite.config.ts): +```typescript +import { VitePWA } from 'vite-plugin-pwa' + +export default defineConfig({ + plugins: [ + react(), + VitePWA({ + registerType: 'autoUpdate', + manifest: { + name: 'Site Name', + short_name: 'Short', + theme_color: '#10b981', + icons: [ + { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, + { src: '/icon-512.png', sizes: '512x512', type: 'image/png' } + ] + }, + workbox: { + globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'] + } + }) + ] +}) +``` + +--- + ### Core Rules Summary - **DB**: Use the single consolidated DB (CRAWLSY_DATABASE_URL → DATABASE_URL); no dual pools; schema_migrations must exist; apply migrations 031/032/033. @@ -293,3 +424,101 @@ ```bash pkill -f "port-forward.*dispensary-scraper" ``` + +25) **Frontend Architecture - AVOID OVER-ENGINEERING** + + **Key Principles:** + - **ONE BACKEND** serves ALL domains (cannaiq.co, findadispo.com, findagram.co) + - Do NOT create separate backend services for each domain + - The existing `dispensary-scraper` backend handles everything + + **Frontend Build Differences:** + - `frontend/` uses **Vite** (outputs to `dist/`, uses `VITE_` env vars) → dispos.crawlsy.com (legacy) + - `cannaiq/` uses **Vite** (outputs to `dist/`, uses `VITE_` env vars) → cannaiq.co (NEW) + - `findadispo/` uses **Create React App** (outputs to `build/`, uses `REACT_APP_` env vars) → findadispo.com + - `findagram/` uses **Create React App** (outputs to `build/`, uses `REACT_APP_` env vars) → findagram.co + + **CRA vs Vite Dockerfile Differences:** + ```dockerfile + # Vite (frontend, cannaiq) + ENV VITE_API_URL=https://api.domain.com + RUN npm run build + COPY --from=builder /app/dist /usr/share/nginx/html + + # CRA (findadispo, findagram) + ENV REACT_APP_API_URL=https://api.domain.com + RUN npm run build + COPY --from=builder /app/build /usr/share/nginx/html + ``` + + **lucide-react Icon Gotchas:** + - Not all icons exist in older versions (e.g., `Cannabis` doesn't exist) + - Use `Leaf` as a substitute for cannabis-related icons + - When doing search/replace for icon names, be careful not to replace text content + - Example: "Cannabis-infused food" should NOT become "Leaf-infused food" + + **Deployment Options:** + 1. **Separate containers** (current): Each frontend in its own nginx container + 2. **Single container** (better): One nginx with multi-domain config serving all frontends + + **Single Container Multi-Domain Approach:** + ```dockerfile + # Build all frontends + FROM node:20-slim AS builder-cannaiq + WORKDIR /app/cannaiq + COPY cannaiq/package*.json ./ + RUN npm install + COPY cannaiq/ ./ + RUN npm run build + + FROM node:20-slim AS builder-findadispo + WORKDIR /app/findadispo + COPY findadispo/package*.json ./ + RUN npm install + COPY findadispo/ ./ + RUN npm run build + + FROM node:20-slim AS builder-findagram + WORKDIR /app/findagram + COPY findagram/package*.json ./ + RUN npm install + COPY findagram/ ./ + RUN npm run build + + # Production nginx with multi-domain routing + FROM nginx:alpine + COPY --from=builder-cannaiq /app/cannaiq/dist /var/www/cannaiq + COPY --from=builder-findadispo /app/findadispo/dist /var/www/findadispo + COPY --from=builder-findagram /app/findagram/build /var/www/findagram + COPY nginx-multi-domain.conf /etc/nginx/conf.d/default.conf + ``` + + **nginx-multi-domain.conf:** + ```nginx + server { + listen 80; + server_name cannaiq.co www.cannaiq.co; + root /var/www/cannaiq; + location / { try_files $uri $uri/ /index.html; } + } + + server { + listen 80; + server_name findadispo.com www.findadispo.com; + root /var/www/findadispo; + location / { try_files $uri $uri/ /index.html; } + } + + server { + listen 80; + server_name findagram.co www.findagram.co; + root /var/www/findagram; + location / { try_files $uri $uri/ /index.html; } + } + ``` + + **Common Mistakes to AVOID:** + - Creating a FastAPI/Express backend just for findagram or findadispo + - Creating separate Docker images per domain when one would work + - Replacing icon names with sed without checking for text content collisions + - Using `npm ci` in Dockerfiles when package-lock.json doesn't exist (use `npm install`) diff --git a/backend/migrations/035_multi_domain_users.sql b/backend/migrations/035_multi_domain_users.sql new file mode 100644 index 00000000..8ba9aca5 --- /dev/null +++ b/backend/migrations/035_multi_domain_users.sql @@ -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; diff --git a/backend/migrations/036_findadispo_dispensary_fields.sql b/backend/migrations/036_findadispo_dispensary_fields.sql new file mode 100644 index 00000000..535343ed --- /dev/null +++ b/backend/migrations/036_findadispo_dispensary_fields.sql @@ -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.'; diff --git a/backend/package.json b/backend/package.json index 424312ce..d2eeba55 100755 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "dutchie-menus-backend", - "version": "1.0.0", + "version": "1.5.1", "description": "Backend API for Dutchie Menus scraper and management", "main": "dist/index.js", "scripts": { diff --git a/backend/src/dutchie-az/routes/index.ts b/backend/src/dutchie-az/routes/index.ts index a16d0680..375799d5 100644 --- a/backend/src/dutchie-az/routes/index.ts +++ b/backend/src/dutchie-az/routes/index.ts @@ -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 // ============================================================ diff --git a/backend/src/index.ts b/backend/src/index.ts index f63d1d24..32f00193 100755 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -64,8 +64,13 @@ import { dutchieAZRouter, startScheduler as startDutchieAZScheduler, initializeD import { trackApiUsage, checkRateLimit } from './middleware/apiTokenTracker'; import { startCrawlScheduler } from './services/crawl-scheduler'; import { validateWordPressPermissions } from './middleware/wordpressPermissions'; +import { markTrustedDomains } from './middleware/trustedDomains'; -// Apply WordPress permissions validation first (sets req.apiToken) +// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com) +// These domains can access the API without authentication +app.use(markTrustedDomains); + +// Apply WordPress permissions validation (sets req.apiToken for API key requests) app.use(validateWordPressPermissions); // Apply API tracking middleware globally diff --git a/backend/src/middleware/trustedDomains.ts b/backend/src/middleware/trustedDomains.ts new file mode 100644 index 00000000..f904dbaf --- /dev/null +++ b/backend/src/middleware/trustedDomains.ts @@ -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(); +} diff --git a/backend/src/routes/public-api.ts b/backend/src/routes/public-api.ts index 8c74cf77..489fe735 100644 --- a/backend/src/routes/public-api.ts +++ b/backend/src/routes/public-api.ts @@ -840,6 +840,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => { d.longitude, d.menu_type as platform, d.menu_url, + d.hours, + d.amenities, + d.description, + d.image_url, + d.google_rating, + d.google_review_count, COALESCE(pc.product_count, 0) as product_count, COALESCE(pc.in_stock_count, 0) as in_stock_count, pc.last_updated @@ -885,6 +891,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => { longitude: parseFloat(d.longitude) } : null, platform: d.platform, + hours: d.hours || null, + amenities: d.amenities || [], + description: d.description || null, + image_url: d.image_url || null, + rating: d.google_rating ? parseFloat(d.google_rating) : null, + review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null, product_count: parseInt(d.product_count || '0', 10), in_stock_count: parseInt(d.in_stock_count || '0', 10), last_updated: d.last_updated, @@ -935,6 +947,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => { d.longitude, d.menu_type as platform, d.menu_url, + d.hours, + d.amenities, + d.description, + d.image_url, + d.google_rating, + d.google_review_count, COALESCE(pc.product_count, 0) as product_count, COALESCE(pc.in_stock_count, 0) as in_stock_count, pc.last_updated @@ -980,6 +998,12 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => { longitude: parseFloat(d.longitude) } : null, platform: d.platform, + hours: d.hours || null, + amenities: d.amenities || [], + description: d.description || null, + image_url: d.image_url || null, + rating: d.google_rating ? parseFloat(d.google_rating) : null, + review_count: d.google_review_count ? parseInt(d.google_review_count, 10) : null, product_count: parseInt(d.product_count || '0', 10), in_stock_count: parseInt(d.in_stock_count || '0', 10), last_updated: d.last_updated, diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index a1b22958..d900350b 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -9,14 +9,36 @@ const router = Router(); router.use(authMiddleware); router.use(requireRole('admin', 'superadmin')); -// Get all users +// Get all users with search and filter router.get('/', async (req: AuthRequest, res) => { try { - const result = await pool.query(` - SELECT id, email, role, created_at, updated_at + const { search, domain } = req.query; + + let query = ` + SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at FROM users - ORDER BY created_at DESC - `); + WHERE 1=1 + `; + const params: any[] = []; + let paramIndex = 1; + + // Search by email, first_name, or last_name + if (search && typeof search === 'string') { + query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + // Filter by domain + if (domain && typeof domain === 'string') { + query += ` AND domain = $${paramIndex}`; + params.push(domain); + paramIndex++; + } + + query += ` ORDER BY created_at DESC`; + + const result = await pool.query(query, params); res.json({ users: result.rows }); } catch (error) { console.error('Error fetching users:', error); @@ -29,7 +51,7 @@ router.get('/:id', async (req: AuthRequest, res) => { try { const { id } = req.params; const result = await pool.query(` - SELECT id, email, role, created_at, updated_at + SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at FROM users WHERE id = $1 `, [id]); @@ -48,7 +70,7 @@ router.get('/:id', async (req: AuthRequest, res) => { // Create user router.post('/', async (req: AuthRequest, res) => { try { - const { email, password, role } = req.body; + const { email, password, role, first_name, last_name, phone, domain } = req.body; if (!email || !password) { return res.status(400).json({ error: 'Email and password are required' }); @@ -60,6 +82,12 @@ router.post('/', async (req: AuthRequest, res) => { return res.status(400).json({ error: 'Invalid role. Must be: admin, analyst, or viewer' }); } + // Check for valid domain + const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com']; + if (domain && !validDomains.includes(domain)) { + return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' }); + } + // Check if email already exists const existing = await pool.query('SELECT id FROM users WHERE email = $1', [email]); if (existing.rows.length > 0) { @@ -70,10 +98,10 @@ router.post('/', async (req: AuthRequest, res) => { const passwordHash = await bcrypt.hash(password, 10); const result = await pool.query(` - INSERT INTO users (email, password_hash, role) - VALUES ($1, $2, $3) - RETURNING id, email, role, created_at, updated_at - `, [email, passwordHash, role || 'viewer']); + INSERT INTO users (email, password_hash, role, first_name, last_name, phone, domain) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at + `, [email, passwordHash, role || 'viewer', first_name || null, last_name || null, phone || null, domain || 'cannaiq.co']); res.status(201).json({ user: result.rows[0] }); } catch (error) { @@ -86,7 +114,7 @@ router.post('/', async (req: AuthRequest, res) => { router.put('/:id', async (req: AuthRequest, res) => { try { const { id } = req.params; - const { email, password, role } = req.body; + const { email, password, role, first_name, last_name, phone, domain } = req.body; // Check if user exists const existing = await pool.query('SELECT id FROM users WHERE id = $1', [id]); @@ -100,6 +128,12 @@ router.put('/:id', async (req: AuthRequest, res) => { return res.status(400).json({ error: 'Invalid role' }); } + // Check for valid domain + const validDomains = ['cannaiq.co', 'findagram.co', 'findadispo.com']; + if (domain && !validDomains.includes(domain)) { + return res.status(400).json({ error: 'Invalid domain. Must be: cannaiq.co, findagram.co, or findadispo.com' }); + } + // Prevent non-superadmin from modifying superadmin users const targetUser = await pool.query('SELECT role FROM users WHERE id = $1', [id]); if (targetUser.rows[0].role === 'superadmin' && req.user?.role !== 'superadmin') { @@ -132,6 +166,27 @@ router.put('/:id', async (req: AuthRequest, res) => { values.push(role); } + // Handle profile fields (allow setting to null with explicit undefined check) + if (first_name !== undefined) { + updates.push(`first_name = $${paramIndex++}`); + values.push(first_name || null); + } + + if (last_name !== undefined) { + updates.push(`last_name = $${paramIndex++}`); + values.push(last_name || null); + } + + if (phone !== undefined) { + updates.push(`phone = $${paramIndex++}`); + values.push(phone || null); + } + + if (domain !== undefined) { + updates.push(`domain = $${paramIndex++}`); + values.push(domain); + } + if (updates.length === 0) { return res.status(400).json({ error: 'No fields to update' }); } @@ -143,7 +198,7 @@ router.put('/:id', async (req: AuthRequest, res) => { UPDATE users SET ${updates.join(', ')} WHERE id = $${paramIndex} - RETURNING id, email, role, created_at, updated_at + RETURNING id, email, role, first_name, last_name, phone, domain, created_at, updated_at `, values); res.json({ user: result.rows[0] }); diff --git a/findadispo/backend/.env.example b/findadispo/backend/.env.example new file mode 100644 index 00000000..2bde2722 --- /dev/null +++ b/findadispo/backend/.env.example @@ -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 diff --git a/findadispo/backend/auth.py b/findadispo/backend/auth.py new file mode 100644 index 00000000..0a89eb53 --- /dev/null +++ b/findadispo/backend/auth.py @@ -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 diff --git a/findadispo/backend/config.py b/findadispo/backend/config.py new file mode 100644 index 00000000..8f569a46 --- /dev/null +++ b/findadispo/backend/config.py @@ -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() diff --git a/findadispo/backend/database.py b/findadispo/backend/database.py new file mode 100644 index 00000000..43f70a9f --- /dev/null +++ b/findadispo/backend/database.py @@ -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) diff --git a/findadispo/backend/models.py b/findadispo/backend/models.py new file mode 100644 index 00000000..595be06c --- /dev/null +++ b/findadispo/backend/models.py @@ -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()) diff --git a/findadispo/backend/requirements.txt b/findadispo/backend/requirements.txt new file mode 100644 index 00000000..1ba60b49 --- /dev/null +++ b/findadispo/backend/requirements.txt @@ -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 diff --git a/findadispo/backend/routes/__init__.py b/findadispo/backend/routes/__init__.py new file mode 100644 index 00000000..d212dab6 --- /dev/null +++ b/findadispo/backend/routes/__init__.py @@ -0,0 +1 @@ +# Routes package diff --git a/findadispo/backend/routes/alerts_routes.py b/findadispo/backend/routes/alerts_routes.py new file mode 100644 index 00000000..d1c4b828 --- /dev/null +++ b/findadispo/backend/routes/alerts_routes.py @@ -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]), + } diff --git a/findadispo/backend/routes/auth_routes.py b/findadispo/backend/routes/auth_routes.py new file mode 100644 index 00000000..1be143ce --- /dev/null +++ b/findadispo/backend/routes/auth_routes.py @@ -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"} diff --git a/findadispo/backend/routes/contact_routes.py b/findadispo/backend/routes/contact_routes.py new file mode 100644 index 00000000..f6f543c9 --- /dev/null +++ b/findadispo/backend/routes/contact_routes.py @@ -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 + } diff --git a/findadispo/backend/routes/dispensary_routes.py b/findadispo/backend/routes/dispensary_routes.py new file mode 100644 index 00000000..2d1eb98c --- /dev/null +++ b/findadispo/backend/routes/dispensary_routes.py @@ -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", []) diff --git a/findadispo/backend/routes/search_routes.py b/findadispo/backend/routes/search_routes.py new file mode 100644 index 00000000..95689a14 --- /dev/null +++ b/findadispo/backend/routes/search_routes.py @@ -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"} diff --git a/findadispo/backend/server.py b/findadispo/backend/server.py new file mode 100644 index 00000000..29b54cb6 --- /dev/null +++ b/findadispo/backend/server.py @@ -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 + ) diff --git a/findadispo/frontend/.env.example b/findadispo/frontend/.env.example new file mode 100644 index 00000000..48001a73 --- /dev/null +++ b/findadispo/frontend/.env.example @@ -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 diff --git a/findadispo/frontend/Dockerfile b/findadispo/frontend/Dockerfile new file mode 100644 index 00000000..dffca9c6 --- /dev/null +++ b/findadispo/frontend/Dockerfile @@ -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;"] diff --git a/findadispo/frontend/package.json b/findadispo/frontend/package.json new file mode 100644 index 00000000..1b8b57ea --- /dev/null +++ b/findadispo/frontend/package.json @@ -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" + ] + } +} diff --git a/findadispo/frontend/postcss.config.js b/findadispo/frontend/postcss.config.js new file mode 100644 index 00000000..33ad091d --- /dev/null +++ b/findadispo/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/findadispo/frontend/public/index.html b/findadispo/frontend/public/index.html new file mode 100644 index 00000000..c154071a --- /dev/null +++ b/findadispo/frontend/public/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Find a Dispensary - Cannabis Dispensary Locator + + + +
+ + diff --git a/findadispo/frontend/public/manifest.json b/findadispo/frontend/public/manifest.json new file mode 100644 index 00000000..8a9783f8 --- /dev/null +++ b/findadispo/frontend/public/manifest.json @@ -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"] +} diff --git a/findadispo/frontend/public/service-worker.js b/findadispo/frontend/public/service-worker.js new file mode 100644 index 00000000..252d4b0f --- /dev/null +++ b/findadispo/frontend/public/service-worker.js @@ -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') + ); + } +}); diff --git a/findadispo/frontend/src/App.js b/findadispo/frontend/src/App.js new file mode 100644 index 00000000..bb491db6 --- /dev/null +++ b/findadispo/frontend/src/App.js @@ -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 ; + } + + return children; +} + +// Main Layout with Header and Footer +function MainLayout({ children }) { + return ( +
+
+
{children}
+
+
+ ); +} + +// Dashboard Layout (no footer, custom header handled in Dashboard component) +function DashboardLayout({ children }) { + return ( +
+
+ {children} +
+ ); +} + +function App() { + return ( + + + {/* Public Routes with Main Layout */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* Auth Routes (no header/footer) */} + } /> + } /> + + {/* Dashboard Routes (protected) */} + + + + + + } + > + } /> + } /> + } /> + } /> + + + {/* Catch-all redirect */} + } /> + + + ); +} + +export default App; diff --git a/findadispo/frontend/src/api/client.js b/findadispo/frontend/src/api/client.js new file mode 100644 index 00000000..92957259 --- /dev/null +++ b/findadispo/frontend/src/api/client.js @@ -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} + */ +export async function getDispensaryBySlug(slugOrId) { + return apiRequest(`/api/v1/dispensaries/${slugOrId}`); +} + +/** + * Fetch dispensary by ID + * @param {number} id - Dispensary ID + * @returns {Promise} + */ +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 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>} + */ +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>} + */ +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 []; + } +} diff --git a/findadispo/frontend/src/components/findadispo/Footer.jsx b/findadispo/frontend/src/components/findadispo/Footer.jsx new file mode 100644 index 00000000..c6731eb1 --- /dev/null +++ b/findadispo/frontend/src/components/findadispo/Footer.jsx @@ -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 ( +
+
+
+ {/* Brand */} +
+ +
+ +
+ Find a Dispensary + +

+ Helping you discover licensed cannabis dispensaries near you. + Find trusted locations, compare options, and access quality products. +

+
+ + {/* Quick Links */} +
+

Quick Links

+
    +
  • + + Home + +
  • +
  • + + Store Locator + +
  • +
  • + + About Us + +
  • +
  • + + Contact + +
  • +
+
+ + {/* Account */} +
+

Account

+
    +
  • + + Log In + +
  • +
  • + + Sign Up + +
  • +
  • + + Dashboard + +
  • +
  • + + Price Alerts + +
  • +
+
+ + {/* Contact */} +
+

Contact Us

+ +
+
+ + {/* Bottom Bar */} +
+
+

+ {currentYear} Find a Dispensary. All rights reserved. +

+
+ + Privacy Policy + + + Terms of Service + +
+
+
+
+
+ ); +} + +export default Footer; diff --git a/findadispo/frontend/src/components/findadispo/Header.jsx b/findadispo/frontend/src/components/findadispo/Header.jsx new file mode 100644 index 00000000..b5e364b0 --- /dev/null +++ b/findadispo/frontend/src/components/findadispo/Header.jsx @@ -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 ( +
+
+ {/* Logo */} + +
+ +
+
+ Find a Dispensary + Cannabis Locator +
+ + + {/* Desktop Navigation */} + + + {/* Auth Buttons */} +
+ {isAuthenticated ? ( + + + + ) : ( + <> + + + + + + + + )} +
+ + {/* Mobile Menu Button */} + +
+ + {/* Mobile Menu */} + {mobileMenuOpen && ( +
+ +
+ )} +
+ ); +} + +export default Header; diff --git a/findadispo/frontend/src/components/ui/badge.jsx b/findadispo/frontend/src/components/ui/badge.jsx new file mode 100644 index 00000000..33a75bdf --- /dev/null +++ b/findadispo/frontend/src/components/ui/badge.jsx @@ -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 ( +
+ ); +} + +export { Badge, badgeVariants }; diff --git a/findadispo/frontend/src/components/ui/button.jsx b/findadispo/frontend/src/components/ui/button.jsx new file mode 100644 index 00000000..15945692 --- /dev/null +++ b/findadispo/frontend/src/components/ui/button.jsx @@ -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 ( + + ); + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/findadispo/frontend/src/components/ui/card.jsx b/findadispo/frontend/src/components/ui/card.jsx new file mode 100644 index 00000000..4ecb517c --- /dev/null +++ b/findadispo/frontend/src/components/ui/card.jsx @@ -0,0 +1,60 @@ +import * as React from "react"; +import { cn } from "../../lib/utils"; + +const Card = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef(({ className, ...props }, ref) => ( +

+)); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef(({ className, ...props }, ref) => ( +
+)); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/findadispo/frontend/src/components/ui/checkbox.jsx b/findadispo/frontend/src/components/ui/checkbox.jsx new file mode 100644 index 00000000..0fb3b418 --- /dev/null +++ b/findadispo/frontend/src/components/ui/checkbox.jsx @@ -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) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/findadispo/frontend/src/components/ui/input.jsx b/findadispo/frontend/src/components/ui/input.jsx new file mode 100644 index 00000000..e2d47d5c --- /dev/null +++ b/findadispo/frontend/src/components/ui/input.jsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { cn } from "../../lib/utils"; + +const Input = React.forwardRef(({ className, type, ...props }, ref) => { + return ( + + ); +}); +Input.displayName = "Input"; + +export { Input }; diff --git a/findadispo/frontend/src/components/ui/label.jsx b/findadispo/frontend/src/components/ui/label.jsx new file mode 100644 index 00000000..05cc479f --- /dev/null +++ b/findadispo/frontend/src/components/ui/label.jsx @@ -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) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/findadispo/frontend/src/components/ui/separator.jsx b/findadispo/frontend/src/components/ui/separator.jsx new file mode 100644 index 00000000..2c6a8338 --- /dev/null +++ b/findadispo/frontend/src/components/ui/separator.jsx @@ -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) => ( + + ) +); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/findadispo/frontend/src/components/ui/slider.jsx b/findadispo/frontend/src/components/ui/slider.jsx new file mode 100644 index 00000000..1a8a63fc --- /dev/null +++ b/findadispo/frontend/src/components/ui/slider.jsx @@ -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) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/findadispo/frontend/src/components/ui/switch.jsx b/findadispo/frontend/src/components/ui/switch.jsx new file mode 100644 index 00000000..e60cee06 --- /dev/null +++ b/findadispo/frontend/src/components/ui/switch.jsx @@ -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) => ( + + + +)); +Switch.displayName = SwitchPrimitives.Root.displayName; + +export { Switch }; diff --git a/findadispo/frontend/src/components/ui/textarea.jsx b/findadispo/frontend/src/components/ui/textarea.jsx new file mode 100644 index 00000000..fdd43514 --- /dev/null +++ b/findadispo/frontend/src/components/ui/textarea.jsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { cn } from "../../lib/utils"; + +const Textarea = React.forwardRef(({ className, ...props }, ref) => { + return ( +