Compare commits

...

25 Commits

Author SHA1 Message Date
Kelly
f25bebf6ee feat: Add wildcard support for trusted domains
Add *.cannaiq.co and *.cannabrands.app to trusted domains list.
Updated isTrustedDomain() to recognize *.domain.com as wildcard
that matches the base domain and any subdomain.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:29:23 -07:00
Kelly
22dad6d0fc feat: Add wildcard trusted origins for cannaiq.co and cannabrands.app
Add *.cannaiq.co and *.cannabrands.app patterns to both:
- auth/middleware.ts (admin routes)
- public-api.ts (consumer /api/v1/* routes)

This allows any subdomain of these domains to access the API without
requiring an API key.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:25:04 -07:00
Kelly
03eab66d35 chore: Bump backend version to 1.6.0
Harmonize backend version with WordPress plugin version so admin UI displays correct version.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 15:06:42 -07:00
Kelly
9fff0ba430 feat(analytics): Add Brand Intelligence API endpoint
New endpoint: GET /api/analytics/v2/brand/:name/intelligence

Returns comprehensive brand analytics payload including:
- Performance snapshot (active SKUs, revenue, stores, market share)
- Alerts (lost stores, delisted SKUs, competitor takeovers)
- SKU performance (velocity, status, stock levels)
- Retail footprint (penetration by region, whitespace opportunities)
- Competitive landscape (price positioning, head-to-head comparisons)
- Inventory health (days of stock, risk levels, overstock alerts)
- Promotion effectiveness (baseline vs promo velocity, lift, ROI)

Supports time windows (7d/30d/90d), state filtering, and category filtering.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 14:53:35 -07:00
kelly
7d3e91b2e6 Merge pull request 'feat(wordpress): Add new Elementor widgets and dynamic selectors v1.6.0' (#17) from feat/wordpress-widgets into master 2025-12-10 20:41:44 +00:00
Kelly
74957a9ec5 feat(wordpress): Add new Elementor widgets and dynamic selectors v1.6.0
New Widgets:
- Brand Grid: Display brands in a grid with product counts
- Category List: Show categories in grid/list/pills layouts
- Specials Grid: Display products on sale with discount badges

Enhanced Product Grid Widget:
- Dynamic category dropdown (fetches from API)
- Dynamic brand dropdown (fetches from API)
- "On Special Only" toggle filter

New Plugin Methods:
- fetch_categories() - Get categories from API
- fetch_brands() - Get brands from API
- fetch_specials() - Get products on sale
- get_category_options() - Cached options for Elementor
- get_brand_options() - Cached options for Elementor

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:41:17 -07:00
kelly
2d035c46cf Merge pull request 'fix: Findagram brands page crash and PWA icon errors' (#16) from fix/findagram-brands-crash into master 2025-12-10 20:11:40 +00:00
Kelly
53445fe72a fix: Findagram brands page crash and PWA icon errors
- Fix mapBrandForUI to use correct 'brand' field from API response
- Add null check in Brands.jsx filter to prevent crash on undefined names
- Fix BrandPenetrationService sps.brand_name -> sps.brand_name_raw
- Remove missing logo192.png and logo512.png from PWA manifest

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 13:06:23 -07:00
kelly
37cc8956c5 Merge pull request 'fix: Join states through dispensaries in BrandPenetrationService' (#15) from feat/ci-auto-merge into master 2025-12-10 19:36:06 +00:00
Kelly
197c82f921 fix: Join states through dispensaries in BrandPenetrationService
The store_products table doesn't have a state_id column - must join
through dispensaries to get state info. Also fixed column references
to use brand_name_raw and category_raw.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 12:18:10 -07:00
kelly
2c52493a9c Merge pull request 'fix(docker): Use npm install instead of npm ci for reliability' (#14) from feat/ci-auto-merge into master 2025-12-10 18:44:21 +00:00
Kelly
2ee2ba6b8c fix(docker): Use npm install instead of npm ci for reliability
npm ci can fail when package-lock.json has minor mismatches with
package.json. npm install is more forgiving and appropriate for
Docker builds where determinism is less critical than reliability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:28:29 -07:00
kelly
bafcf1694a Merge pull request 'feat(analytics): Brand promotional history + specials fix + API key editing' (#13) from feat/ci-auto-merge into master
Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/13
2025-12-10 18:12:59 +00:00
Kelly
95792aab15 feat(analytics): Brand promotional history + specials fix + API key editing
- Add brand promotional history endpoint (GET /api/analytics/v2/brand/:name/promotions)
  - Tracks when products go on special, duration, discounts, quantity sold estimates
  - Aggregates by category with frequency metrics (weekly/monthly)
- Add quantity changes endpoint (GET /api/analytics/v2/store/:id/quantity-changes)
  - Filter by direction (increase/decrease/all) for sales vs restock estimation
- Fix canonical-upsert to populate stock_quantity and total_quantity_available
- Add API key edit functionality in admin UI
  - Edit allowed domains and IPs
  - Display domains in list view

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 10:59:03 -07:00
kelly
38ae2c3a3e Merge pull request 'feat/ci-auto-merge' (#12) from feat/ci-auto-merge into master
Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/12
2025-12-10 17:26:21 +00:00
Kelly
249d3c1b7f fix: Build args format for version info + schema-tolerant routes
CI/CD:
- Fix build_args format in woodpecker CI (comma-separated, not YAML list)
- This fixes "unknown" SHA/version showing on remote deployments

Backend schema-tolerant fixes (graceful fallbacks when tables missing):
- users.ts: Check which columns exist before querying
- worker-registry.ts: Return empty result if table doesn't exist
- task-service.ts: Add tableExists() helper, handle missing tables/views
- proxies.ts: Return totalProxies in test-all response

Frontend fixes:
- Proxies: Use total from response for accurate progress display
- SEO PagesTab: Dim Generate button when no AI provider active

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:53:21 -07:00
Kelly
9647f94f89 fix: Copy migrations folder to Docker image + fix SQL FILTER syntax
- Dockerfile: Add COPY migrations ./migrations so auto-migrate works on remote
- intelligence.ts: Fix FILTER clause placement in aggregate functions
  - FILTER must be inside AVG(), not wrapping ROUND()
  - Remove redundant FILTER on MIN (already filtered by WHERE)
  - Remove unsupported FILTER on PERCENTILE_CONT

These fixes resolve:
- "Failed to get task counts" (worker_tasks table missing)
- "FILTER specified but round is not an aggregate function" errors
- /national page "column m.state does not exist" (mv_state_metrics missing)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:38:05 -07:00
Kelly
afc288d2cf feat(ci): Auto-merge PRs after all type checks pass
Uses Gitea API to merge PR automatically when all typecheck jobs succeed.
Requires gitea_token secret in Woodpecker.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 09:27:26 -07:00
kelly
df01ce6aad Merge pull request 'feat: Auto-migrations on startup, worker exit location, proxy improvements' (#11) from feat/auto-migrations into master
Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/11
2025-12-10 16:07:17 +00:00
Kelly
aea93bc96b fix(ci): Revert volume caching - may have broken CI trigger 2025-12-10 08:53:10 -07:00
Kelly
4e84f30f8b feat: Auto-retry tasks, 403 proxy rotation, task deletion
- Fix 403 handler to rotate BOTH proxy and fingerprint (was only fingerprint)
- Add auto-retry logic to task service (retry up to max_retries before failing)
- Add error tooltip on task status badge showing retry count and error message
- Add DELETE /api/tasks/:id endpoint (only for non-running tasks)
- Add delete button to JobQueue task table

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:41:14 -07:00
Kelly
b20a0a4fa5 fix: Add generic delete method to ApiClient + CI speedups
- Add delete<T>() method to ApiClient for WorkersDashboard cleanup
- Add npm cache volume for faster npm ci
- Add TypeScript incremental builds with tsBuildInfoFile cache
- Should significantly speed up repeated CI runs

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:27:11 -07:00
Kelly
6eb1babc86 feat: Auto-migrations on startup, worker exit location, proxy improvements
- Add auto-migration system that runs SQL files from migrations/ on server startup
- Track applied migrations in schema_migrations table
- Show proxy exit location in Workers dashboard
- Add "Cleanup Stale" button to remove old workers
- Add remove button for individual workers
- Include proxy location (city, state, country) in worker heartbeats
- Update Proxy interface with location fields
- Re-enable bulk proxy import without ON CONFLICT

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 08:05:24 -07:00
kelly
9a9c2f76a2 Merge pull request 'feat: Stealth worker system with mandatory proxy rotation' (#10) from feat/stealth-worker-system into master
Reviewed-on: https://code.cannabrands.app/Creationshop/dispensary-scraper/pulls/10
2025-12-10 08:13:42 +00:00
Kelly
56cc171287 feat: Stealth worker system with mandatory proxy rotation
## Worker System
- Role-agnostic workers that can handle any task type
- Pod-based architecture with StatefulSet (5-15 pods, 5 workers each)
- Custom pod names (Aethelgard, Xylos, Kryll, etc.)
- Worker registry with friendly names and resource monitoring
- Hub-and-spoke visualization on JobQueue page

## Stealth & Anti-Detection (REQUIRED)
- Proxies are MANDATORY - workers fail to start without active proxies
- CrawlRotator initializes on worker startup
- Loads proxies from `proxies` table
- Auto-rotates proxy + fingerprint on 403 errors
- 12 browser fingerprints (Chrome, Firefox, Safari, Edge)
- Locale/timezone matching for geographic consistency

## Task System
- Renamed product_resync → product_refresh
- Task chaining: store_discovery → entry_point → product_discovery
- Priority-based claiming with FOR UPDATE SKIP LOCKED
- Heartbeat and stale task recovery

## UI Updates
- JobQueue: Pod visualization, resource monitoring on hover
- WorkersDashboard: Simplified worker list
- Removed unused filters from task list

## Other
- IP2Location service for visitor analytics
- Findagram consumer features scaffolding
- Documentation updates

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 00:44:59 -07:00
93 changed files with 12835 additions and 2246 deletions

View File

@@ -45,6 +45,31 @@ steps:
when:
event: pull_request
# ===========================================
# AUTO-MERGE: Merge PR after all checks pass
# ===========================================
auto-merge:
image: alpine:latest
environment:
GITEA_TOKEN:
from_secret: gitea_token
commands:
- apk add --no-cache curl
- |
echo "Merging PR #${CI_COMMIT_PULL_REQUEST}..."
curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d '{"Do":"merge"}' \
"https://code.cannabrands.app/api/v1/repos/Creationshop/dispensary-scraper/pulls/${CI_COMMIT_PULL_REQUEST}/merge"
depends_on:
- typecheck-backend
- typecheck-cannaiq
- typecheck-findadispo
- typecheck-findagram
when:
event: pull_request
# ===========================================
# MASTER DEPLOY: Parallel Docker builds
# ===========================================
@@ -64,11 +89,7 @@ steps:
from_secret: registry_password
platforms: linux/amd64
provenance: false
build_args:
- APP_BUILD_VERSION=${CI_COMMIT_SHA}
- APP_GIT_SHA=${CI_COMMIT_SHA}
- APP_BUILD_TIME=${CI_PIPELINE_CREATED}
- CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
build_args: APP_BUILD_VERSION=${CI_COMMIT_SHA:0:8},APP_GIT_SHA=${CI_COMMIT_SHA},APP_BUILD_TIME=${CI_PIPELINE_CREATED},CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
depends_on: []
when:
branch: master

View File

@@ -213,22 +213,23 @@ CannaiQ has **TWO databases** with distinct purposes:
| Table | Purpose | Row Count |
|-------|---------|-----------|
| `dispensaries` | Store/dispensary records | ~188+ rows |
| `dutchie_products` | Product catalog | ~37,000+ rows |
| `dutchie_product_snapshots` | Price/stock history | ~millions |
| `store_products` | Canonical product schema | ~37,000+ rows |
| `store_product_snapshots` | Canonical snapshot schema | growing |
| `store_products` | Product catalog | ~37,000+ rows |
| `store_product_snapshots` | Price/stock history | ~millions |
**LEGACY TABLES (EMPTY - DO NOT USE):**
| Table | Status | Action |
|-------|--------|--------|
| `stores` | EMPTY (0 rows) | Use `dispensaries` instead |
| `products` | EMPTY (0 rows) | Use `dutchie_products` or `store_products` |
| `products` | EMPTY (0 rows) | Use `store_products` instead |
| `dutchie_products` | LEGACY (0 rows) | Use `store_products` instead |
| `dutchie_product_snapshots` | LEGACY (0 rows) | Use `store_product_snapshots` instead |
| `categories` | EMPTY (0 rows) | Categories stored in product records |
**Code must NEVER:**
- Query the `stores` table (use `dispensaries`)
- Query the `products` table (use `dutchie_products` or `store_products`)
- Query the `products` table (use `store_products`)
- Query the `dutchie_products` table (use `store_products`)
- Query the `categories` table (categories are in product records)
**CRITICAL RULES:**
@@ -343,23 +344,23 @@ npx tsx src/scripts/etl/042_legacy_import.ts
- SCHEMA ONLY - no data inserts from legacy tables
**ETL Script 042** (`backend/src/scripts/etl/042_legacy_import.ts`):
- Copies data from `dutchie_products``store_products`
- Copies data from `dutchie_product_snapshots``store_product_snapshots`
- Copies data from legacy `dutchie_legacy.dutchie_products``store_products`
- Copies data from legacy `dutchie_legacy.dutchie_product_snapshots``store_product_snapshots`
- Extracts brands from product data into `brands` table
- Links dispensaries to chains and states
- INSERT-ONLY and IDEMPOTENT (uses ON CONFLICT DO NOTHING)
- Run manually: `cd backend && npx tsx src/scripts/etl/042_legacy_import.ts`
**Tables touched by ETL:**
| Source Table | Target Table |
|--------------|--------------|
| Source Table (dutchie_legacy) | Target Table (dutchie_menus) |
|-------------------------------|------------------------------|
| `dutchie_products` | `store_products` |
| `dutchie_product_snapshots` | `store_product_snapshots` |
| (brand names extracted) | `brands` |
| (state codes mapped) | `dispensaries.state_id` |
| (chain names matched) | `dispensaries.chain_id` |
**Legacy tables remain intact** - `dutchie_products` and `dutchie_product_snapshots` are not modified.
**Note:** The legacy `dutchie_products` and `dutchie_product_snapshots` tables in `dutchie_legacy` are read-only sources. All new crawl data goes directly to `store_products` and `store_product_snapshots`.
**Migration 045** (`backend/migrations/045_add_image_columns.sql`):
- Adds `thumbnail_url` to `store_products` and `store_product_snapshots`
@@ -881,7 +882,7 @@ export default defineConfig({
18) **Dashboard Architecture**
- **Frontend**: Rebuild the frontend with `VITE_API_URL` pointing to the correct backend and redeploy.
- **Backend**: `/api/dashboard/stats` MUST use the canonical DB pool. Use the correct tables: `dutchie_products`, `dispensaries`, and views like `v_dashboard_stats`, `v_latest_snapshots`.
- **Backend**: `/api/dashboard/stats` MUST use the canonical DB pool. Use the correct tables: `store_products`, `dispensaries`, and views like `v_dashboard_stats`, `v_latest_snapshots`.
19) **Deployment (Gitea + Kubernetes)**
- **Registry**: Gitea at `code.cannabrands.app/creationshop/dispensary-scraper`

3
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# IP2Location database (downloaded separately)
data/ip2location/

View File

@@ -5,7 +5,7 @@ FROM code.cannabrands.app/creationshop/node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npm install
COPY . .
RUN npm run build
@@ -43,10 +43,13 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
RUN npm install --omit=dev
COPY --from=builder /app/dist ./dist
# Copy migrations for auto-migrate on startup
COPY migrations ./migrations
# Create local images directory for when MinIO is not configured
RUN mkdir -p /app/public/images/products

View File

@@ -0,0 +1,394 @@
# Brand Intelligence API
## Endpoint
```
GET /api/analytics/v2/brand/:name/intelligence
```
## Query Parameters
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `window` | `7d\|30d\|90d` | `30d` | Time window for trend calculations |
| `state` | string | - | Filter by state code (e.g., `AZ`) |
| `category` | string | - | Filter by category (e.g., `Flower`) |
## Response Payload Schema
```typescript
interface BrandIntelligenceResult {
brand_name: string;
window: '7d' | '30d' | '90d';
generated_at: string; // ISO timestamp when data was computed
performance_snapshot: PerformanceSnapshot;
alerts: Alerts;
sku_performance: SkuPerformance[];
retail_footprint: RetailFootprint;
competitive_landscape: CompetitiveLandscape;
inventory_health: InventoryHealth;
promo_performance: PromoPerformance;
}
```
---
## Section 1: Performance Snapshot
Summary cards with key brand metrics.
```typescript
interface PerformanceSnapshot {
active_skus: number; // Total products in catalog
total_revenue_30d: number | null; // Estimated from qty × price
total_stores: number; // Active retail partners
new_stores_30d: number; // New distribution in window
market_share: number | null; // % of category SKUs
avg_wholesale_price: number | null;
price_position: 'premium' | 'value' | 'competitive';
}
```
**UI Label Mapping:**
| Field | User-Facing Label | Helper Text |
|-------|-------------------|-------------|
| `active_skus` | Active Products | X total in catalog |
| `total_revenue_30d` | Monthly Revenue | Estimated from sales |
| `total_stores` | Retail Distribution | Active retail partners |
| `new_stores_30d` | New Opportunities | X new in last 30 days |
| `market_share` | Category Position | % of category |
| `avg_wholesale_price` | Avg Wholesale | Per unit |
| `price_position` | Pricing Tier | Premium/Value/Market Rate |
---
## Section 2: Alerts
Issues requiring attention.
```typescript
interface Alerts {
lost_stores_30d_count: number;
lost_skus_30d_count: number;
competitor_takeover_count: number;
avg_oos_duration_days: number | null;
avg_reorder_lag_days: number | null;
items: AlertItem[];
}
interface AlertItem {
type: 'lost_store' | 'delisted_sku' | 'shelf_loss' | 'extended_oos';
severity: 'critical' | 'warning';
store_name?: string;
product_name?: string;
competitor_brand?: string;
days_since?: number;
state_code?: string;
}
```
**UI Label Mapping:**
| Field | User-Facing Label |
|-------|-------------------|
| `lost_stores_30d_count` | Accounts at Risk |
| `lost_skus_30d_count` | Delisted SKUs |
| `competitor_takeover_count` | Shelf Losses |
| `avg_oos_duration_days` | Avg Stockout Length |
| `avg_reorder_lag_days` | Avg Restock Time |
| `severity: critical` | Urgent |
| `severity: warning` | Watch |
---
## Section 3: SKU Performance (Product Velocity)
How fast each SKU sells.
```typescript
interface SkuPerformance {
store_product_id: number;
product_name: string;
category: string | null;
daily_velocity: number; // Units/day estimate
velocity_status: 'hot' | 'steady' | 'slow' | 'stale';
retail_price: number | null;
on_sale: boolean;
stores_carrying: number;
stock_status: 'in_stock' | 'low_stock' | 'out_of_stock';
}
```
**UI Label Mapping:**
| Field | User-Facing Label |
|-------|-------------------|
| `daily_velocity` | Daily Rate |
| `velocity_status` | Momentum |
| `velocity_status: hot` | Hot |
| `velocity_status: steady` | Steady |
| `velocity_status: slow` | Slow |
| `velocity_status: stale` | Stale |
| `retail_price` | Retail Price |
| `on_sale` | Promo (badge) |
**Velocity Thresholds:**
- `hot`: >= 5 units/day
- `steady`: >= 1 unit/day
- `slow`: >= 0.1 units/day
- `stale`: < 0.1 units/day
---
## Section 4: Retail Footprint
Store placement and coverage.
```typescript
interface RetailFootprint {
total_stores: number;
in_stock_count: number;
out_of_stock_count: number;
penetration_by_region: RegionPenetration[];
whitespace_stores: WhitespaceStore[];
}
interface RegionPenetration {
state_code: string;
store_count: number;
percent_reached: number; // % of state's dispensaries
in_stock: number;
out_of_stock: number;
}
interface WhitespaceStore {
store_id: number;
store_name: string;
state_code: string;
city: string | null;
category_fit: number; // How many competing brands they carry
competitor_brands: string[];
}
```
**UI Label Mapping:**
| Field | User-Facing Label |
|-------|-------------------|
| `penetration_by_region` | Market Coverage by Region |
| `percent_reached` | X% reached |
| `in_stock` | X stocked |
| `out_of_stock` | X out |
| `whitespace_stores` | Expansion Opportunities |
| `category_fit` | X fit |
---
## Section 5: Competitive Landscape
Market positioning vs competitors.
```typescript
interface CompetitiveLandscape {
brand_price_position: 'premium' | 'value' | 'competitive';
market_share_trend: MarketSharePoint[];
competitors: Competitor[];
head_to_head_skus: HeadToHead[];
}
interface MarketSharePoint {
date: string;
share_percent: number;
}
interface Competitor {
brand_name: string;
store_overlap_percent: number;
price_position: 'premium' | 'value' | 'competitive';
avg_price: number | null;
sku_count: number;
}
interface HeadToHead {
product_name: string;
brand_price: number;
competitor_brand: string;
competitor_price: number;
price_diff_percent: number;
}
```
**UI Label Mapping:**
| Field | User-Facing Label |
|-------|-------------------|
| `price_position: premium` | Premium Tier |
| `price_position: value` | Value Leader |
| `price_position: competitive` | Market Rate |
| `market_share_trend` | Share of Shelf Trend |
| `head_to_head_skus` | Price Comparison |
| `store_overlap_percent` | X% store overlap |
---
## Section 6: Inventory Health
Stock projections and risk levels.
```typescript
interface InventoryHealth {
critical_count: number; // <7 days stock
warning_count: number; // 7-14 days stock
healthy_count: number; // 14-90 days stock
overstocked_count: number; // >90 days stock
skus: InventorySku[];
overstock_alert: OverstockItem[];
}
interface InventorySku {
store_product_id: number;
product_name: string;
store_name: string;
days_of_stock: number | null;
risk_level: 'critical' | 'elevated' | 'moderate' | 'healthy';
current_quantity: number | null;
daily_sell_rate: number | null;
}
interface OverstockItem {
product_name: string;
store_name: string;
excess_units: number;
days_of_stock: number;
}
```
**UI Label Mapping:**
| Field | User-Facing Label |
|-------|-------------------|
| `risk_level: critical` | Reorder Now |
| `risk_level: elevated` | Low Stock |
| `risk_level: moderate` | Monitor |
| `risk_level: healthy` | Healthy |
| `critical_count` | Urgent (<7 days) |
| `warning_count` | Low (7-14 days) |
| `overstocked_count` | Excess (>90 days) |
| `days_of_stock` | X days remaining |
| `overstock_alert` | Overstock Alert |
| `excess_units` | X excess units |
---
## Section 7: Promotion Effectiveness
How promotions impact sales.
```typescript
interface PromoPerformance {
avg_baseline_velocity: number | null;
avg_promo_velocity: number | null;
avg_velocity_lift: number | null; // % increase during promo
avg_efficiency_score: number | null; // ROI proxy
promotions: Promotion[];
}
interface Promotion {
product_name: string;
store_name: string;
status: 'active' | 'scheduled' | 'ended';
start_date: string;
end_date: string | null;
regular_price: number;
promo_price: number;
discount_percent: number;
baseline_velocity: number | null;
promo_velocity: number | null;
velocity_lift: number | null;
efficiency_score: number | null;
}
```
**UI Label Mapping:**
| Field | User-Facing Label |
|-------|-------------------|
| `avg_baseline_velocity` | Normal Rate |
| `avg_promo_velocity` | During Promos |
| `avg_velocity_lift` | Avg Sales Lift |
| `avg_efficiency_score` | ROI Score |
| `velocity_lift` | Sales Lift |
| `efficiency_score` | ROI Score |
| `status: active` | Live |
| `status: scheduled` | Scheduled |
| `status: ended` | Ended |
---
## Example Queries
### Get full payload
```javascript
const response = await fetch('/api/analytics/v2/brand/Wyld/intelligence?window=30d');
const data = await response.json();
```
### Extract summary cards (flattened)
```javascript
const { performance_snapshot: ps, alerts } = data;
const summaryCards = {
activeProducts: ps.active_skus,
monthlyRevenue: ps.total_revenue_30d,
retailDistribution: ps.total_stores,
newOpportunities: ps.new_stores_30d,
categoryPosition: ps.market_share,
avgWholesale: ps.avg_wholesale_price,
pricingTier: ps.price_position,
accountsAtRisk: alerts.lost_stores_30d_count,
delistedSkus: alerts.lost_skus_30d_count,
shelfLosses: alerts.competitor_takeover_count,
};
```
### Get top 10 fastest selling SKUs
```javascript
const topSkus = data.sku_performance
.filter(sku => sku.velocity_status === 'hot' || sku.velocity_status === 'steady')
.sort((a, b) => b.daily_velocity - a.daily_velocity)
.slice(0, 10);
```
### Get critical inventory alerts only
```javascript
const criticalInventory = data.inventory_health.skus
.filter(sku => sku.risk_level === 'critical');
```
### Get states with <50% penetration
```javascript
const underPenetrated = data.retail_footprint.penetration_by_region
.filter(region => region.percent_reached < 50)
.sort((a, b) => a.percent_reached - b.percent_reached);
```
### Get active promotions with positive lift
```javascript
const effectivePromos = data.promo_performance.promotions
.filter(p => p.status === 'active' && p.velocity_lift > 0)
.sort((a, b) => b.velocity_lift - a.velocity_lift);
```
### Build chart data for market share trend
```javascript
const chartData = data.competitive_landscape.market_share_trend.map(point => ({
x: new Date(point.date),
y: point.share_percent,
}));
```
---
## Notes for Frontend Implementation
1. **All fields are snake_case** - transform to camelCase if needed
2. **Null values are possible** - handle gracefully in UI
3. **Arrays may be empty** - show appropriate empty states
4. **Timestamps are ISO format** - parse with `new Date()`
5. **Percentages are already computed** - no need to multiply by 100
6. **The `window` parameter affects trend calculations** - 7d/30d/90d

View File

@@ -275,6 +275,22 @@ Store metadata:
---
## Worker Roles
Workers pull tasks from the `worker_tasks` queue based on their assigned role.
| Role | Name | Description | Handler |
|------|------|-------------|---------|
| `product_resync` | Product Resync | Re-crawl dispensary products for price/stock changes | `handleProductResync` |
| `product_discovery` | Product Discovery | Initial product discovery for new dispensaries | `handleProductDiscovery` |
| `store_discovery` | Store Discovery | Discover new dispensary locations | `handleStoreDiscovery` |
| `entry_point_discovery` | Entry Point Discovery | Resolve platform IDs from menu URLs | `handleEntryPointDiscovery` |
| `analytics_refresh` | Analytics Refresh | Refresh materialized views and analytics | `handleAnalyticsRefresh` |
**API Endpoint:** `GET /api/worker-registry/roles`
---
## Scheduling
Crawls are scheduled via `worker_tasks` table:
@@ -282,8 +298,219 @@ Crawls are scheduled via `worker_tasks` table:
| Role | Frequency | Description |
|------|-----------|-------------|
| `product_resync` | Every 4 hours | Regular product refresh |
| `product_discovery` | On-demand | First crawl for new stores |
| `entry_point_discovery` | On-demand | New store setup |
| `store_discovery` | Daily | Find new stores |
| `analytics_refresh` | Daily | Refresh analytics materialized views |
---
## Priority & On-Demand Tasks
Tasks are claimed by workers in order of **priority DESC, created_at ASC**.
### Priority Levels
| Priority | Use Case | Example |
|----------|----------|---------|
| 0 | Scheduled/batch tasks | Daily product_resync generation |
| 10 | On-demand/chained tasks | entry_point → product_discovery |
| Higher | Urgent/manual triggers | Admin-triggered immediate crawl |
### Task Chaining
When a task completes, the system automatically creates follow-up tasks:
```
store_discovery (completed)
└─► entry_point_discovery (priority: 10) for each new store
entry_point_discovery (completed, success)
└─► product_discovery (priority: 10) for that store
product_discovery (completed)
└─► [no chain] Store enters regular resync schedule
```
### On-Demand Task Creation
Use the task service to create high-priority tasks:
```typescript
// Create immediate product resync for a store
await taskService.createTask({
role: 'product_resync',
dispensary_id: 123,
platform: 'dutchie',
priority: 20, // Higher than batch tasks
});
// Convenience methods with default high priority (10)
await taskService.createEntryPointTask(dispensaryId, 'dutchie');
await taskService.createProductDiscoveryTask(dispensaryId, 'dutchie');
await taskService.createStoreDiscoveryTask('dutchie', 'AZ');
```
### Claim Function
The `claim_task()` SQL function atomically claims tasks:
- Respects priority ordering (higher = first)
- Uses `FOR UPDATE SKIP LOCKED` for concurrency
- Prevents multiple active tasks per store
---
## Image Storage
Images are downloaded from Dutchie's AWS S3 and stored locally with on-demand resizing.
### Storage Path
```
/storage/images/products/<state>/<store>/<brand>/<product_id>/image-<hash>.webp
/storage/images/brands/<brand>/logo-<hash>.webp
```
**Example:**
```
/storage/images/products/az/az-deeply-rooted/bud-bros/6913e3cd444eac3935e928b9/image-ae38b1f9.webp
```
### Image Proxy API
Served via `/img/*` with on-demand resizing using **sharp**:
```
GET /img/products/az/az-deeply-rooted/bud-bros/6913e3cd444eac3935e928b9/image-ae38b1f9.webp?w=200
```
| Param | Description |
|-------|-------------|
| `w` | Width in pixels (max 4000) |
| `h` | Height in pixels (max 4000) |
| `q` | Quality 1-100 (default 80) |
| `fit` | cover, contain, fill, inside, outside |
| `blur` | Blur sigma (0.3-1000) |
| `gray` | Grayscale (1 = enabled) |
| `format` | webp, jpeg, png, avif (default webp) |
### Key Files
| File | Purpose |
|------|---------|
| `src/utils/image-storage.ts` | Download & save images to local filesystem |
| `src/routes/image-proxy.ts` | On-demand resize/transform at `/img/*` |
### Download Rules
| Scenario | Image Action |
|----------|--------------|
| **New product (first crawl)** | Download if `primaryImageUrl` exists |
| **Existing product (refresh)** | Download only if `local_image_path` is NULL (backfill) |
| **Product already has local image** | Skip download entirely |
**Logic:**
- Images are downloaded **once** and never re-downloaded on subsequent crawls
- `skipIfExists: true` - filesystem check prevents re-download even if queued
- First crawl: all products get images
- Refresh crawl: only new products or products missing local images
### Storage Rules
- **NO MinIO** - local filesystem only (`STORAGE_DRIVER=local`)
- Store full resolution, resize on-demand via `/img` proxy
- Convert to webp for consistency using **sharp**
- Preserve original Dutchie URL as fallback in `image_url` column
- Local path stored in `local_image_path` column
---
## Stealth & Anti-Detection
**PROXIES ARE REQUIRED** - Workers will fail to start if no active proxies are available in the database. All HTTP requests to Dutchie go through a proxy.
Workers automatically initialize anti-detection systems on startup.
### Components
| Component | Purpose | Source |
|-----------|---------|--------|
| **CrawlRotator** | Coordinates proxy + UA rotation | `src/services/crawl-rotator.ts` |
| **ProxyRotator** | Round-robin proxy selection, health tracking | `src/services/crawl-rotator.ts` |
| **UserAgentRotator** | Cycles through realistic browser fingerprints | `src/services/crawl-rotator.ts` |
| **Dutchie Client** | Curl-based HTTP with auto-retry on 403 | `src/platforms/dutchie/client.ts` |
### Initialization Flow
```
Worker Start
├─► initializeStealth()
│ │
│ ├─► CrawlRotator.initialize()
│ │ └─► Load proxies from `proxies` table
│ │
│ └─► setCrawlRotator(rotator)
│ └─► Wire to Dutchie client
└─► Process tasks...
```
### Stealth Session (per task)
Each crawl task starts a stealth session:
```typescript
// In product-refresh.ts, entry-point-discovery.ts
const session = startSession(dispensary.state || 'AZ', 'America/Phoenix');
```
This creates a new identity with:
- **Random fingerprint:** Chrome/Firefox/Safari/Edge on Win/Mac/Linux
- **Accept-Language:** Matches timezone (e.g., `America/Phoenix``en-US,en;q=0.9`)
- **sec-ch-ua headers:** Proper Client Hints for the browser profile
### On 403 Block
When Dutchie returns 403, the client automatically:
1. Records failure on current proxy (increments `failure_count`)
2. If proxy has 5+ failures, deactivates it
3. Rotates to next healthy proxy
4. Rotates fingerprint
5. Retries the request
### Proxy Table Schema
```sql
CREATE TABLE proxies (
id SERIAL PRIMARY KEY,
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
username VARCHAR(100),
password VARCHAR(100),
protocol VARCHAR(10) DEFAULT 'http', -- http, https, socks5
is_active BOOLEAN DEFAULT true,
last_used_at TIMESTAMPTZ,
failure_count INTEGER DEFAULT 0,
success_count INTEGER DEFAULT 0,
avg_response_time_ms INTEGER,
last_failure_at TIMESTAMPTZ,
last_error TEXT
);
```
### Configuration
Proxies are mandatory. There is no environment variable to disable them. Workers will refuse to start without active proxies in the database.
### Fingerprints Available
The client includes 6 browser fingerprints:
- Chrome 131 on Windows
- Chrome 131 on macOS
- Chrome 120 on Windows
- Firefox 133 on Windows
- Safari 17.2 on macOS
- Edge 131 on Windows
Each includes proper `sec-ch-ua`, `sec-ch-ua-platform`, and `sec-ch-ua-mobile` headers.
---
@@ -293,6 +520,7 @@ Crawls are scheduled via `worker_tasks` table:
- **Normalization errors:** Logged as warnings, continue with valid products
- **Image download errors:** Non-fatal, logged, continue
- **Database errors:** Task fails, will be retried
- **403 blocks:** Auto-rotate proxy + fingerprint, retry (up to 3 retries)
---
@@ -305,4 +533,6 @@ Crawls are scheduled via `worker_tasks` table:
| `src/platforms/dutchie/index.ts` | GraphQL client, session management |
| `src/hydration/normalizers/dutchie.ts` | Payload normalization |
| `src/hydration/canonical-upsert.ts` | Database upsert logic |
| `src/utils/image-storage.ts` | Image download and local storage |
| `src/routes/image-proxy.ts` | On-demand image resizing |
| `migrations/075_consecutive_misses.sql` | OOS tracking column |

View File

@@ -0,0 +1,69 @@
apiVersion: batch/v1
kind: CronJob
metadata:
name: ip2location-update
namespace: default
spec:
# Run on the 1st of every month at 3am UTC
schedule: "0 3 1 * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
containers:
- name: ip2location-updater
image: curlimages/curl:latest
command:
- /bin/sh
- -c
- |
set -e
echo "Downloading IP2Location LITE DB5..."
# Download to temp
cd /tmp
curl -L -o ip2location.zip "https://www.ip2location.com/download/?token=${IP2LOCATION_TOKEN}&file=DB5LITEBIN"
# Extract
unzip -o ip2location.zip
# Find and copy the BIN file
BIN_FILE=$(ls *.BIN 2>/dev/null | head -1)
if [ -z "$BIN_FILE" ]; then
echo "ERROR: No BIN file found"
exit 1
fi
# Copy to shared volume
cp "$BIN_FILE" /data/IP2LOCATION-LITE-DB5.BIN
echo "Done! Database updated: /data/IP2LOCATION-LITE-DB5.BIN"
env:
- name: IP2LOCATION_TOKEN
valueFrom:
secretKeyRef:
name: dutchie-backend-secret
key: IP2LOCATION_TOKEN
volumeMounts:
- name: ip2location-data
mountPath: /data
restartPolicy: OnFailure
volumes:
- name: ip2location-data
persistentVolumeClaim:
claimName: ip2location-pvc
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: ip2location-pvc
namespace: default
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi

View File

@@ -26,6 +26,12 @@ spec:
name: dutchie-backend-config
- secretRef:
name: dutchie-backend-secret
env:
- name: IP2LOCATION_DB_PATH
value: /data/ip2location/IP2LOCATION-LITE-DB5.BIN
volumeMounts:
- name: ip2location-data
mountPath: /data/ip2location
resources:
requests:
memory: "256Mi"
@@ -45,3 +51,7 @@ spec:
port: 3010
initialDelaySeconds: 5
periodSeconds: 5
volumes:
- name: ip2location-data
persistentVolumeClaim:
claimName: ip2location-pvc

View File

@@ -0,0 +1,71 @@
-- Visitor location analytics for Findagram
-- Tracks visitor locations to understand popular areas
CREATE TABLE IF NOT EXISTS visitor_locations (
id SERIAL PRIMARY KEY,
-- Location data (from IP lookup)
ip_hash VARCHAR(64), -- Hashed IP for privacy (SHA256)
city VARCHAR(100),
state VARCHAR(100),
state_code VARCHAR(10),
country VARCHAR(100),
country_code VARCHAR(10),
latitude DECIMAL(10, 7),
longitude DECIMAL(10, 7),
-- Visit metadata
domain VARCHAR(50) NOT NULL, -- 'findagram.co', 'findadispo.com', etc.
page_path VARCHAR(255), -- '/products', '/dispensaries/123', etc.
referrer VARCHAR(500),
user_agent VARCHAR(500),
-- Session tracking
session_id VARCHAR(64), -- For grouping page views in a session
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for analytics queries
CREATE INDEX IF NOT EXISTS idx_visitor_locations_domain ON visitor_locations(domain);
CREATE INDEX IF NOT EXISTS idx_visitor_locations_city_state ON visitor_locations(city, state_code);
CREATE INDEX IF NOT EXISTS idx_visitor_locations_created_at ON visitor_locations(created_at);
CREATE INDEX IF NOT EXISTS idx_visitor_locations_session ON visitor_locations(session_id);
-- Aggregated daily stats (materialized for performance)
CREATE TABLE IF NOT EXISTS visitor_location_stats (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
domain VARCHAR(50) NOT NULL,
city VARCHAR(100),
state VARCHAR(100),
state_code VARCHAR(10),
country_code VARCHAR(10),
-- Metrics
visit_count INTEGER DEFAULT 0,
unique_sessions INTEGER DEFAULT 0,
UNIQUE(date, domain, city, state_code, country_code)
);
CREATE INDEX IF NOT EXISTS idx_visitor_stats_date ON visitor_location_stats(date);
CREATE INDEX IF NOT EXISTS idx_visitor_stats_domain ON visitor_location_stats(domain);
CREATE INDEX IF NOT EXISTS idx_visitor_stats_state ON visitor_location_stats(state_code);
-- View for easy querying of top locations
CREATE OR REPLACE VIEW v_top_visitor_locations AS
SELECT
domain,
city,
state,
state_code,
country_code,
COUNT(*) as total_visits,
COUNT(DISTINCT session_id) as unique_sessions,
MAX(created_at) as last_visit
FROM visitor_locations
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY domain, city, state, state_code, country_code
ORDER BY total_visits DESC;

View File

@@ -0,0 +1,141 @@
-- Migration 076: Worker Registry for Dynamic Workers
-- Workers register on startup, receive a friendly name, and report heartbeats
-- Name pool for workers (expandable, no hardcoding)
CREATE TABLE IF NOT EXISTS worker_name_pool (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
in_use BOOLEAN DEFAULT FALSE,
assigned_to VARCHAR(100), -- worker_id
assigned_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Seed with initial names (can add more via API)
INSERT INTO worker_name_pool (name) VALUES
('Alice'), ('Bella'), ('Clara'), ('Diana'), ('Elena'),
('Fiona'), ('Grace'), ('Hazel'), ('Iris'), ('Julia'),
('Katie'), ('Luna'), ('Mia'), ('Nora'), ('Olive'),
('Pearl'), ('Quinn'), ('Rosa'), ('Sara'), ('Tara'),
('Uma'), ('Vera'), ('Wendy'), ('Xena'), ('Yuki'), ('Zara'),
('Amber'), ('Blake'), ('Coral'), ('Dawn'), ('Echo'),
('Fleur'), ('Gem'), ('Haven'), ('Ivy'), ('Jade'),
('Kira'), ('Lotus'), ('Maple'), ('Nova'), ('Onyx'),
('Pixel'), ('Quest'), ('Raven'), ('Sage'), ('Terra'),
('Unity'), ('Violet'), ('Willow'), ('Xylo'), ('Yara'), ('Zen')
ON CONFLICT (name) DO NOTHING;
-- Worker registry - tracks active workers
CREATE TABLE IF NOT EXISTS worker_registry (
id SERIAL PRIMARY KEY,
worker_id VARCHAR(100) UNIQUE NOT NULL, -- e.g., "pod-abc123" or uuid
friendly_name VARCHAR(50), -- assigned from pool
role VARCHAR(50) NOT NULL, -- task role
pod_name VARCHAR(100), -- k8s pod name
hostname VARCHAR(100), -- machine hostname
ip_address VARCHAR(50), -- worker IP
status VARCHAR(20) DEFAULT 'starting', -- starting, active, idle, offline, terminated
started_at TIMESTAMPTZ DEFAULT NOW(),
last_heartbeat_at TIMESTAMPTZ DEFAULT NOW(),
last_task_at TIMESTAMPTZ,
tasks_completed INTEGER DEFAULT 0,
tasks_failed INTEGER DEFAULT 0,
current_task_id INTEGER,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for worker registry
CREATE INDEX IF NOT EXISTS idx_worker_registry_status ON worker_registry(status);
CREATE INDEX IF NOT EXISTS idx_worker_registry_role ON worker_registry(role);
CREATE INDEX IF NOT EXISTS idx_worker_registry_heartbeat ON worker_registry(last_heartbeat_at);
-- Function to assign a name to a new worker
CREATE OR REPLACE FUNCTION assign_worker_name(p_worker_id VARCHAR(100))
RETURNS VARCHAR(50) AS $$
DECLARE
v_name VARCHAR(50);
BEGIN
-- Try to get an unused name
UPDATE worker_name_pool
SET in_use = TRUE, assigned_to = p_worker_id, assigned_at = NOW()
WHERE id = (
SELECT id FROM worker_name_pool
WHERE in_use = FALSE
ORDER BY RANDOM()
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING name INTO v_name;
-- If no names available, generate one
IF v_name IS NULL THEN
v_name := 'Worker-' || SUBSTRING(p_worker_id FROM 1 FOR 8);
END IF;
RETURN v_name;
END;
$$ LANGUAGE plpgsql;
-- Function to release a worker's name back to the pool
CREATE OR REPLACE FUNCTION release_worker_name(p_worker_id VARCHAR(100))
RETURNS VOID AS $$
BEGIN
UPDATE worker_name_pool
SET in_use = FALSE, assigned_to = NULL, assigned_at = NULL
WHERE assigned_to = p_worker_id;
END;
$$ LANGUAGE plpgsql;
-- Function to mark stale workers as offline
CREATE OR REPLACE FUNCTION mark_stale_workers(stale_threshold_minutes INTEGER DEFAULT 5)
RETURNS INTEGER AS $$
DECLARE
v_count INTEGER;
BEGIN
UPDATE worker_registry
SET status = 'offline', updated_at = NOW()
WHERE status IN ('active', 'idle', 'starting')
AND last_heartbeat_at < NOW() - (stale_threshold_minutes || ' minutes')::INTERVAL
RETURNING COUNT(*) INTO v_count;
-- Release names from offline workers
PERFORM release_worker_name(worker_id)
FROM worker_registry
WHERE status = 'offline'
AND last_heartbeat_at < NOW() - INTERVAL '30 minutes';
RETURN COALESCE(v_count, 0);
END;
$$ LANGUAGE plpgsql;
-- View for dashboard
CREATE OR REPLACE VIEW v_active_workers AS
SELECT
wr.id,
wr.worker_id,
wr.friendly_name,
wr.role,
wr.status,
wr.pod_name,
wr.hostname,
wr.started_at,
wr.last_heartbeat_at,
wr.last_task_at,
wr.tasks_completed,
wr.tasks_failed,
wr.current_task_id,
EXTRACT(EPOCH FROM (NOW() - wr.last_heartbeat_at)) as seconds_since_heartbeat,
CASE
WHEN wr.status = 'offline' THEN 'offline'
WHEN wr.last_heartbeat_at < NOW() - INTERVAL '2 minutes' THEN 'stale'
WHEN wr.current_task_id IS NOT NULL THEN 'busy'
ELSE 'ready'
END as health_status
FROM worker_registry wr
WHERE wr.status != 'terminated'
ORDER BY wr.status = 'active' DESC, wr.last_heartbeat_at DESC;
COMMENT ON TABLE worker_registry IS 'Tracks all workers that have registered with the system';
COMMENT ON TABLE worker_name_pool IS 'Pool of friendly names for workers - expandable via API';

View File

@@ -0,0 +1,35 @@
-- Migration: Add visitor location and dispensary name to click events
-- Captures where visitors are clicking from and which dispensary
-- Add visitor location columns
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS visitor_city VARCHAR(100);
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS visitor_state VARCHAR(10);
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS visitor_lat DECIMAL(10, 7);
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS visitor_lng DECIMAL(10, 7);
-- Add dispensary name for easier reporting
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS dispensary_name VARCHAR(255);
-- Create index for location-based analytics
CREATE INDEX IF NOT EXISTS idx_product_click_events_visitor_state
ON product_click_events(visitor_state)
WHERE visitor_state IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_product_click_events_visitor_city
ON product_click_events(visitor_city)
WHERE visitor_city IS NOT NULL;
-- Add comments
COMMENT ON COLUMN product_click_events.visitor_city IS 'City where the visitor is located (from IP geolocation)';
COMMENT ON COLUMN product_click_events.visitor_state IS 'State where the visitor is located (from IP geolocation)';
COMMENT ON COLUMN product_click_events.visitor_lat IS 'Visitor latitude (from IP geolocation)';
COMMENT ON COLUMN product_click_events.visitor_lng IS 'Visitor longitude (from IP geolocation)';
COMMENT ON COLUMN product_click_events.dispensary_name IS 'Name of the dispensary (denormalized for easier reporting)';

View File

@@ -1026,6 +1026,17 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csv-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
"bin": {
"csv-parser": "bin/csv-parser"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/data-uri-to-buffer": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
@@ -2235,6 +2246,14 @@
"node": ">= 12"
}
},
"node_modules/ip2location-nodejs": {
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/ip2location-nodejs/-/ip2location-nodejs-9.7.0.tgz",
"integrity": "sha512-eQ4T5TXm1cx0+pQcRycPiuaiRuoDEMd9O89Be7Ugk555qi9UY9enXSznkkqr3kQRyUaXx7zj5dORC5LGTPOttA==",
"dependencies": {
"csv-parser": "^3.0.0"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",

View File

@@ -21,6 +21,7 @@
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"ioredis": "^5.8.2",
"ip2location-nodejs": "^9.7.0",
"ipaddr.js": "^2.2.0",
"jsonwebtoken": "^9.0.2",
"minio": "^7.1.3",
@@ -1531,6 +1532,17 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csv-parser": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/csv-parser/-/csv-parser-3.2.0.tgz",
"integrity": "sha512-fgKbp+AJbn1h2dcAHKIdKNSSjfp43BZZykXsCjzALjKy80VXQNHPFJ6T9Afwdzoj24aMkq8GwDS7KGcDPpejrA==",
"bin": {
"csv-parser": "bin/csv-parser"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/data-uri-to-buffer": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
@@ -2754,6 +2766,14 @@
"node": ">= 12"
}
},
"node_modules/ip2location-nodejs": {
"version": "9.7.0",
"resolved": "https://registry.npmjs.org/ip2location-nodejs/-/ip2location-nodejs-9.7.0.tgz",
"integrity": "sha512-eQ4T5TXm1cx0+pQcRycPiuaiRuoDEMd9O89Be7Ugk555qi9UY9enXSznkkqr3kQRyUaXx7zj5dORC5LGTPOttA==",
"dependencies": {
"csv-parser": "^3.0.0"
}
},
"node_modules/ipaddr.js": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "dutchie-menus-backend",
"version": "1.5.1",
"version": "1.6.0",
"description": "Backend API for Dutchie Menus scraper and management",
"main": "dist/index.js",
"scripts": {
@@ -35,6 +35,7 @@
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"ioredis": "^5.8.2",
"ip2location-nodejs": "^9.7.0",
"ipaddr.js": "^2.2.0",
"jsonwebtoken": "^9.0.2",
"minio": "^7.1.3",

View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Download IP2Location LITE DB3 (City-level) database
# Free for commercial use with attribution
# https://lite.ip2location.com/database/db3-ip-country-region-city
set -e
DATA_DIR="${1:-./data/ip2location}"
DB_FILE="IP2LOCATION-LITE-DB3.BIN"
mkdir -p "$DATA_DIR"
cd "$DATA_DIR"
echo "Downloading IP2Location LITE DB3 database..."
# IP2Location LITE DB3 - includes city, region, country, lat/lng
# You need to register at https://lite.ip2location.com/ to get a download token
# Then set IP2LOCATION_TOKEN environment variable
if [ -z "$IP2LOCATION_TOKEN" ]; then
echo ""
echo "ERROR: IP2LOCATION_TOKEN not set"
echo ""
echo "To download the database:"
echo "1. Register free at https://lite.ip2location.com/"
echo "2. Get your download token from the dashboard"
echo "3. Run: IP2LOCATION_TOKEN=your_token ./scripts/download-ip2location.sh"
echo ""
exit 1
fi
# Download DB3.LITE (IPv4 + City)
DOWNLOAD_URL="https://www.ip2location.com/download/?token=${IP2LOCATION_TOKEN}&file=DB3LITEBIN"
echo "Downloading from IP2Location..."
curl -L -o ip2location.zip "$DOWNLOAD_URL"
echo "Extracting..."
unzip -o ip2location.zip
# Rename to standard name
if [ -f "IP2LOCATION-LITE-DB3.BIN" ]; then
echo "Database ready: $DATA_DIR/IP2LOCATION-LITE-DB3.BIN"
elif [ -f "IP-COUNTRY-REGION-CITY.BIN" ]; then
mv "IP-COUNTRY-REGION-CITY.BIN" "$DB_FILE"
echo "Database ready: $DATA_DIR/$DB_FILE"
else
# Find whatever BIN file was extracted
BIN_FILE=$(ls *.BIN 2>/dev/null | head -1)
if [ -n "$BIN_FILE" ]; then
mv "$BIN_FILE" "$DB_FILE"
echo "Database ready: $DATA_DIR/$DB_FILE"
else
echo "ERROR: No BIN file found in archive"
ls -la
exit 1
fi
fi
# Cleanup
rm -f ip2location.zip *.txt LICENSE* README*
echo ""
echo "Done! Database saved to: $DATA_DIR/$DB_FILE"
echo "Update monthly by re-running this script."

View File

@@ -32,6 +32,7 @@ const TRUSTED_ORIGINS = [
// Pattern-based trusted origins (wildcards)
const TRUSTED_ORIGIN_PATTERNS = [
/^https:\/\/.*\.cannabrands\.app$/, // *.cannabrands.app
/^https:\/\/.*\.cannaiq\.co$/, // *.cannaiq.co
];
// Trusted IPs for internal pod-to-pod communication

View File

@@ -0,0 +1,141 @@
/**
* Auto-Migration System
*
* Runs SQL migration files from the migrations/ folder automatically on server startup.
* Uses a schema_migrations table to track which migrations have been applied.
*
* Safe to run multiple times - only applies new migrations.
*/
import { Pool } from 'pg';
import fs from 'fs';
import path from 'path';
const MIGRATIONS_DIR = path.join(__dirname, '../../migrations');
/**
* Ensure schema_migrations table exists
*/
async function ensureMigrationsTable(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`);
}
/**
* Get list of already-applied migrations
*/
async function getAppliedMigrations(pool: Pool): Promise<Set<string>> {
const result = await pool.query('SELECT name FROM schema_migrations');
return new Set(result.rows.map(row => row.name));
}
/**
* Get list of migration files from disk
*/
function getMigrationFiles(): string[] {
if (!fs.existsSync(MIGRATIONS_DIR)) {
console.log('[AutoMigrate] No migrations directory found');
return [];
}
return fs.readdirSync(MIGRATIONS_DIR)
.filter(f => f.endsWith('.sql'))
.sort(); // Sort alphabetically (001_, 002_, etc.)
}
/**
* Run a single migration file
*/
async function runMigration(pool: Pool, filename: string): Promise<void> {
const filepath = path.join(MIGRATIONS_DIR, filename);
const sql = fs.readFileSync(filepath, 'utf8');
const client = await pool.connect();
try {
await client.query('BEGIN');
// Run the migration SQL
await client.query(sql);
// Record that this migration was applied
await client.query(
'INSERT INTO schema_migrations (name) VALUES ($1) ON CONFLICT (name) DO NOTHING',
[filename]
);
await client.query('COMMIT');
console.log(`[AutoMigrate] ✓ Applied: ${filename}`);
} catch (error: any) {
await client.query('ROLLBACK');
console.error(`[AutoMigrate] ✗ Failed: ${filename}`);
throw error;
} finally {
client.release();
}
}
/**
* Run all pending migrations
*
* @param pool - Database connection pool
* @returns Number of migrations applied
*/
export async function runAutoMigrations(pool: Pool): Promise<number> {
console.log('[AutoMigrate] Checking for pending migrations...');
try {
// Ensure migrations table exists
await ensureMigrationsTable(pool);
// Get applied and available migrations
const applied = await getAppliedMigrations(pool);
const available = getMigrationFiles();
// Find pending migrations
const pending = available.filter(f => !applied.has(f));
if (pending.length === 0) {
console.log('[AutoMigrate] No pending migrations');
return 0;
}
console.log(`[AutoMigrate] Found ${pending.length} pending migrations`);
// Run each pending migration in order
for (const filename of pending) {
await runMigration(pool, filename);
}
console.log(`[AutoMigrate] Successfully applied ${pending.length} migrations`);
return pending.length;
} catch (error: any) {
console.error('[AutoMigrate] Migration failed:', error.message);
// Don't crash the server - log and continue
// The specific failing migration will have been rolled back
return -1;
}
}
/**
* Check migration status without running anything
*/
export async function checkMigrationStatus(pool: Pool): Promise<{
applied: string[];
pending: string[];
}> {
await ensureMigrationsTable(pool);
const applied = await getAppliedMigrations(pool);
const available = getMigrationFiles();
return {
applied: available.filter(f => applied.has(f)),
pending: available.filter(f => !applied.has(f)),
};
}

View File

@@ -191,6 +191,23 @@ export async function runFullDiscovery(
}
}
// Step 5: Detect dropped stores (in DB but not in discovery results)
if (!dryRun) {
console.log('\n[Discovery] Step 5: Detecting dropped stores...');
const droppedResult = await detectDroppedStores(pool, stateCode);
if (droppedResult.droppedCount > 0) {
console.log(`[Discovery] Found ${droppedResult.droppedCount} dropped stores:`);
droppedResult.droppedStores.slice(0, 10).forEach(s => {
console.log(` - ${s.name} (${s.city}, ${s.state}) - last seen: ${s.lastSeenAt}`);
});
if (droppedResult.droppedCount > 10) {
console.log(` ... and ${droppedResult.droppedCount - 10} more`);
}
} else {
console.log(`[Discovery] No dropped stores detected`);
}
}
return {
cities: cityResult,
locations: locationResults,
@@ -200,6 +217,107 @@ export async function runFullDiscovery(
};
}
// ============================================================
// DROPPED STORE DETECTION
// ============================================================
export interface DroppedStoreResult {
droppedCount: number;
droppedStores: Array<{
id: number;
name: string;
city: string;
state: string;
platformDispensaryId: string;
lastSeenAt: string;
}>;
}
/**
* Detect stores that exist in dispensaries but were not found in discovery.
* Marks them as status='dropped' for manual review.
*
* A store is considered "dropped" if:
* 1. It has a platform_dispensary_id (was verified via Dutchie)
* 2. It was NOT seen in the latest discovery crawl (last_seen_at in discovery < 24h ago)
* 3. It's currently marked as 'open' status
*/
export async function detectDroppedStores(
pool: Pool,
stateCode?: string
): Promise<DroppedStoreResult> {
// Find dispensaries that:
// 1. Have platform_dispensary_id (verified Dutchie stores)
// 2. Are currently 'open' status
// 3. Have a linked discovery record that wasn't seen in the last discovery run
// (last_seen_at in dutchie_discovery_locations is older than 24 hours)
const params: any[] = [];
let stateFilter = '';
if (stateCode) {
stateFilter = ` AND d.state = $1`;
params.push(stateCode);
}
const query = `
WITH recently_seen AS (
SELECT DISTINCT platform_location_id
FROM dutchie_discovery_locations
WHERE last_seen_at > NOW() - INTERVAL '24 hours'
AND active = true
)
SELECT
d.id,
d.name,
d.city,
d.state,
d.platform_dispensary_id,
d.updated_at as last_seen_at
FROM dispensaries d
WHERE d.platform_dispensary_id IS NOT NULL
AND d.platform = 'dutchie'
AND (d.status = 'open' OR d.status IS NULL)
AND d.crawl_enabled = true
AND d.platform_dispensary_id NOT IN (SELECT platform_location_id FROM recently_seen)
${stateFilter}
ORDER BY d.name
`;
const result = await pool.query(query, params);
const droppedStores = result.rows;
// Mark these stores as 'dropped' status
if (droppedStores.length > 0) {
const ids = droppedStores.map(s => s.id);
await pool.query(`
UPDATE dispensaries
SET status = 'dropped', updated_at = NOW()
WHERE id = ANY($1::int[])
`, [ids]);
// Log to promotion log for audit
for (const store of droppedStores) {
await pool.query(`
INSERT INTO dutchie_promotion_log
(dispensary_id, action, state_code, store_name, triggered_by)
VALUES ($1, 'dropped', $2, $3, 'discovery_detection')
`, [store.id, store.state, store.name]);
}
}
return {
droppedCount: droppedStores.length,
droppedStores: droppedStores.map(s => ({
id: s.id,
name: s.name,
city: s.city,
state: s.state,
platformDispensaryId: s.platform_dispensary_id,
lastSeenAt: s.last_seen_at,
})),
};
}
// ============================================================
// SINGLE CITY DISCOVERY
// ============================================================

View File

@@ -90,7 +90,7 @@ export async function upsertStoreProducts(
name_raw, brand_name_raw, category_raw, subcategory_raw,
price_rec, price_med, price_rec_special, price_med_special,
is_on_special, discount_percent,
is_in_stock, stock_status,
is_in_stock, stock_status, stock_quantity, total_quantity_available,
thc_percent, cbd_percent,
image_url,
first_seen_at, last_seen_at, updated_at
@@ -99,9 +99,9 @@ export async function upsertStoreProducts(
$5, $6, $7, $8,
$9, $10, $11, $12,
$13, $14,
$15, $16,
$17, $18,
$19,
$15, $16, $17, $17,
$18, $19,
$20,
NOW(), NOW(), NOW()
)
ON CONFLICT (dispensary_id, provider, provider_product_id)
@@ -118,6 +118,8 @@ export async function upsertStoreProducts(
discount_percent = EXCLUDED.discount_percent,
is_in_stock = EXCLUDED.is_in_stock,
stock_status = EXCLUDED.stock_status,
stock_quantity = EXCLUDED.stock_quantity,
total_quantity_available = EXCLUDED.total_quantity_available,
thc_percent = EXCLUDED.thc_percent,
cbd_percent = EXCLUDED.cbd_percent,
image_url = EXCLUDED.image_url,
@@ -141,6 +143,7 @@ export async function upsertStoreProducts(
productPricing?.discountPercent,
productAvailability?.inStock ?? true,
productAvailability?.stockStatus || 'unknown',
productAvailability?.quantity ?? null, // stock_quantity and total_quantity_available
// Clamp THC/CBD to valid percentage range (0-100) - some products report mg as %
product.thcPercent !== null && product.thcPercent <= 100 ? product.thcPercent : null,
product.cbdPercent !== null && product.cbdPercent <= 100 ? product.cbdPercent : null,

View File

@@ -6,6 +6,8 @@ import { initializeMinio, isMinioEnabled } from './utils/minio';
import { initializeImageStorage } from './utils/image-storage';
import { logger } from './services/logger';
import { cleanupOrphanedJobs } from './services/proxyTestQueue';
import { runAutoMigrations } from './db/auto-migrate';
import { getPool } from './db/pool';
import healthRoutes from './routes/health';
import imageProxyRoutes from './routes/image-proxy';
@@ -127,7 +129,6 @@ import { createStatesRouter } from './routes/states';
import { createAnalyticsV2Router } from './routes/analytics-v2';
import { createDiscoveryRoutes } from './discovery';
import pipelineRoutes from './routes/pipeline';
import { getPool } from './db/pool';
// Consumer API routes (findadispo.com, findagram.co)
import consumerAuthRoutes from './routes/consumer-auth';
@@ -140,6 +141,7 @@ import clickAnalyticsRoutes from './routes/click-analytics';
import seoRoutes from './routes/seo';
import priceAnalyticsRoutes from './routes/price-analytics';
import tasksRoutes from './routes/tasks';
import workerRegistryRoutes from './routes/worker-registry';
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
// These domains can access the API without authentication
@@ -216,6 +218,10 @@ console.log('[Workers] Routes registered at /api/workers, /api/monitor, and /api
app.use('/api/tasks', tasksRoutes);
console.log('[Tasks] Routes registered at /api/tasks');
// Worker registry - dynamic worker registration, heartbeats, and name management
app.use('/api/worker-registry', workerRegistryRoutes);
console.log('[WorkerRegistry] Routes registered at /api/worker-registry');
// Phase 3: Analytics V2 - Enhanced analytics with rec/med state segmentation
try {
const analyticsV2Router = createAnalyticsV2Router(getPool());
@@ -302,6 +308,17 @@ async function startServer() {
try {
logger.info('system', 'Starting server...');
// Run auto-migrations before anything else
const pool = getPool();
const migrationsApplied = await runAutoMigrations(pool);
if (migrationsApplied > 0) {
logger.info('system', `Applied ${migrationsApplied} database migrations`);
} else if (migrationsApplied === 0) {
logger.info('system', 'Database schema up to date');
} else {
logger.warn('system', 'Some migrations failed - check logs');
}
await initializeMinio();
await initializeImageStorage();
logger.info('system', isMinioEnabled() ? 'MinIO storage initialized' : 'Local filesystem storage initialized');

View File

@@ -5,8 +5,8 @@ import { Request, Response, NextFunction } from 'express';
* These are our own frontends that should have unrestricted access.
*/
const TRUSTED_DOMAINS = [
'cannaiq.co',
'www.cannaiq.co',
'*.cannaiq.co',
'*.cannabrands.app',
'findagram.co',
'www.findagram.co',
'findadispo.com',
@@ -32,6 +32,24 @@ function extractDomain(header: string): string | null {
}
}
/**
* Checks if a domain matches any trusted domain (supports *.domain.com wildcards)
*/
function isTrustedDomain(domain: string): boolean {
for (const trusted of TRUSTED_DOMAINS) {
if (trusted.startsWith('*.')) {
// Wildcard: *.example.com matches example.com and any subdomain
const baseDomain = trusted.slice(2);
if (domain === baseDomain || domain.endsWith('.' + baseDomain)) {
return true;
}
} else if (domain === trusted) {
return true;
}
}
return false;
}
/**
* Checks if the request comes from a trusted domain
*/
@@ -42,7 +60,7 @@ function isRequestFromTrustedDomain(req: Request): boolean {
// Check Origin header first (preferred for CORS requests)
if (origin) {
const domain = extractDomain(origin);
if (domain && TRUSTED_DOMAINS.includes(domain)) {
if (domain && isTrustedDomain(domain)) {
return true;
}
}
@@ -50,7 +68,7 @@ function isRequestFromTrustedDomain(req: Request): boolean {
// Fallback to Referer header
if (referer) {
const domain = extractDomain(referer);
if (domain && TRUSTED_DOMAINS.includes(domain)) {
if (domain && isTrustedDomain(domain)) {
return true;
}
}

View File

@@ -534,7 +534,8 @@ export async function executeGraphQL(
}
if (response.status === 403 && retryOn403) {
console.warn(`[Dutchie Client] 403 blocked - rotating fingerprint...`);
console.warn(`[Dutchie Client] 403 blocked - rotating proxy and fingerprint...`);
await rotateProxyOn403('403 Forbidden on GraphQL');
rotateFingerprint();
attempt++;
await sleep(1000 * attempt);
@@ -617,7 +618,8 @@ export async function fetchPage(
}
if (response.status === 403 && retryOn403) {
console.warn(`[Dutchie Client] 403 blocked - rotating fingerprint...`);
console.warn(`[Dutchie Client] 403 blocked - rotating proxy and fingerprint...`);
await rotateProxyOn403('403 Forbidden on page fetch');
rotateFingerprint();
attempt++;
await sleep(1000 * attempt);

View File

@@ -16,6 +16,7 @@ import { BrandPenetrationService } from '../services/analytics/BrandPenetrationS
import { CategoryAnalyticsService } from '../services/analytics/CategoryAnalyticsService';
import { StoreAnalyticsService } from '../services/analytics/StoreAnalyticsService';
import { StateAnalyticsService } from '../services/analytics/StateAnalyticsService';
import { BrandIntelligenceService } from '../services/analytics/BrandIntelligenceService';
import { TimeWindow, LegalType } from '../services/analytics/types';
function parseTimeWindow(window?: string): TimeWindow {
@@ -41,6 +42,7 @@ export function createAnalyticsV2Router(pool: Pool): Router {
const categoryService = new CategoryAnalyticsService(pool);
const storeService = new StoreAnalyticsService(pool);
const stateService = new StateAnalyticsService(pool);
const brandIntelligenceService = new BrandIntelligenceService(pool);
// ============================================================
// PRICE ANALYTICS
@@ -231,6 +233,76 @@ export function createAnalyticsV2Router(pool: Pool): Router {
}
});
/**
* GET /brand/:name/promotions
* Get brand promotional history - tracks specials, discounts, duration, and sales estimates
*
* Query params:
* - window: 7d|30d|90d (default: 90d)
* - state: state code filter (e.g., AZ)
* - category: category filter (e.g., Flower)
*/
router.get('/brand/:name/promotions', async (req: Request, res: Response) => {
try {
const brandName = decodeURIComponent(req.params.name);
const window = parseTimeWindow(req.query.window as string) || '90d';
const stateCode = req.query.state as string | undefined;
const category = req.query.category as string | undefined;
const result = await brandService.getBrandPromotionalHistory(brandName, {
window,
stateCode,
category,
});
res.json(result);
} catch (error) {
console.error('[AnalyticsV2] Brand promotions error:', error);
res.status(500).json({ error: 'Failed to fetch brand promotional history' });
}
});
/**
* GET /brand/:name/intelligence
* Get comprehensive B2B brand intelligence dashboard data
*
* Returns all brand metrics in a single unified response:
* - Performance Snapshot (active SKUs, revenue, stores, market share)
* - Alerts/Slippage (lost stores, delisted SKUs, competitor takeovers)
* - Product Velocity (daily rates, velocity status)
* - Retail Footprint (penetration, whitespace opportunities)
* - Competitive Landscape (price position, market share trend)
* - Inventory Health (days of stock, risk levels)
* - Promotion Effectiveness (baseline vs promo velocity, ROI)
*
* Query params:
* - window: 7d|30d|90d (default: 30d)
* - state: state code filter (e.g., AZ)
* - category: category filter (e.g., Flower)
*/
router.get('/brand/:name/intelligence', async (req: Request, res: Response) => {
try {
const brandName = decodeURIComponent(req.params.name);
const window = parseTimeWindow(req.query.window as string);
const stateCode = req.query.state as string | undefined;
const category = req.query.category as string | undefined;
const result = await brandIntelligenceService.getBrandIntelligence(brandName, {
window,
stateCode,
category,
});
if (!result) {
return res.status(404).json({ error: 'Brand not found' });
}
res.json(result);
} catch (error) {
console.error('[AnalyticsV2] Brand intelligence error:', error);
res.status(500).json({ error: 'Failed to fetch brand intelligence' });
}
});
// ============================================================
// CATEGORY ANALYTICS
// ============================================================
@@ -400,6 +472,31 @@ export function createAnalyticsV2Router(pool: Pool): Router {
}
});
/**
* GET /store/:id/quantity-changes
* Get quantity changes for a store (increases/decreases)
* Useful for estimating sales (decreases) or restocks (increases)
*
* Query params:
* - window: 7d|30d|90d (default: 7d)
* - direction: increase|decrease|all (default: all)
* - limit: number (default: 100)
*/
router.get('/store/:id/quantity-changes', async (req: Request, res: Response) => {
try {
const dispensaryId = parseInt(req.params.id);
const window = parseTimeWindow(req.query.window as string);
const direction = (req.query.direction as 'increase' | 'decrease' | 'all') || 'all';
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
const result = await storeService.getQuantityChanges(dispensaryId, { window, direction, limit });
res.json(result);
} catch (error) {
console.error('[AnalyticsV2] Store quantity changes error:', error);
res.status(500).json({ error: 'Failed to fetch store quantity changes' });
}
});
/**
* GET /store/:id/inventory
* Get store inventory composition

View File

@@ -5,33 +5,37 @@ import { pool } from '../db/pool';
const router = Router();
router.use(authMiddleware);
// Get categories (flat list)
// Get categories (flat list) - derived from actual product data
router.get('/', async (req, res) => {
try {
const { store_id } = req.query;
const { store_id, in_stock_only } = req.query;
let query = `
SELECT
c.*,
COUNT(DISTINCT p.id) as product_count,
pc.name as parent_name
FROM categories c
LEFT JOIN store_products p ON c.name = p.category_raw
LEFT JOIN categories pc ON c.parent_id = pc.id
category_raw as name,
category_raw as slug,
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE is_in_stock = true) as in_stock_count
FROM store_products
WHERE category_raw IS NOT NULL
`;
const params: any[] = [];
if (store_id) {
query += ' WHERE c.store_id = $1';
params.push(store_id);
query += ` AND dispensary_id = $${params.length}`;
}
if (in_stock_only === 'true') {
query += ` AND is_in_stock = true`;
}
query += `
GROUP BY c.id, pc.name
ORDER BY c.display_order, c.name
GROUP BY category_raw
ORDER BY category_raw
`;
const result = await pool.query(query, params);
res.json({ categories: result.rows });
} catch (error) {
@@ -40,50 +44,86 @@ router.get('/', async (req, res) => {
}
});
// Get category tree (hierarchical)
// Get category tree (hierarchical) - category -> subcategory structure from product data
router.get('/tree', async (req, res) => {
try {
const { store_id } = req.query;
if (!store_id) {
return res.status(400).json({ error: 'store_id is required' });
}
// Get all categories for the store
const result = await pool.query(`
SELECT
c.*,
COUNT(DISTINCT p.id) as product_count
FROM categories c
LEFT JOIN store_products p ON c.name = p.category_raw AND p.is_in_stock = true AND p.dispensary_id = $1
WHERE c.store_id = $1
GROUP BY c.id
ORDER BY c.display_order, c.name
`, [store_id]);
// Build tree structure
const categories = result.rows;
const categoryMap = new Map();
const tree: any[] = [];
// First pass: create map
categories.forEach((cat: { id: number; parent_id?: number }) => {
categoryMap.set(cat.id, { ...cat, children: [] });
});
const { store_id, in_stock_only } = req.query;
// Second pass: build tree
categories.forEach((cat: { id: number; parent_id?: number }) => {
const node = categoryMap.get(cat.id);
if (cat.parent_id) {
const parent = categoryMap.get(cat.parent_id);
if (parent) {
parent.children.push(node);
}
} else {
tree.push(node);
// Get category + subcategory combinations with counts
let query = `
SELECT
category_raw as category,
subcategory_raw as subcategory,
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE is_in_stock = true) as in_stock_count
FROM store_products
WHERE category_raw IS NOT NULL
`;
const params: any[] = [];
if (store_id) {
params.push(store_id);
query += ` AND dispensary_id = $${params.length}`;
}
if (in_stock_only === 'true') {
query += ` AND is_in_stock = true`;
}
query += `
GROUP BY category_raw, subcategory_raw
ORDER BY category_raw, subcategory_raw
`;
const result = await pool.query(query, params);
// Build tree structure: category -> subcategories
const categoryMap = new Map<string, {
name: string;
slug: string;
product_count: number;
in_stock_count: number;
subcategories: Array<{
name: string;
slug: string;
product_count: number;
in_stock_count: number;
}>;
}>();
for (const row of result.rows) {
const category = row.category;
const subcategory = row.subcategory;
const count = parseInt(row.product_count);
const inStockCount = parseInt(row.in_stock_count);
if (!categoryMap.has(category)) {
categoryMap.set(category, {
name: category,
slug: category.toLowerCase().replace(/\s+/g, '-'),
product_count: 0,
in_stock_count: 0,
subcategories: []
});
}
});
const cat = categoryMap.get(category)!;
cat.product_count += count;
cat.in_stock_count += inStockCount;
if (subcategory) {
cat.subcategories.push({
name: subcategory,
slug: subcategory.toLowerCase().replace(/\s+/g, '-'),
product_count: count,
in_stock_count: inStockCount
});
}
}
const tree = Array.from(categoryMap.values());
res.json({ tree });
} catch (error) {
console.error('Error fetching category tree:', error);
@@ -91,4 +131,91 @@ router.get('/tree', async (req, res) => {
}
});
// Get all unique subcategories for a category
router.get('/:category/subcategories', async (req, res) => {
try {
const { category } = req.params;
const { store_id, in_stock_only } = req.query;
let query = `
SELECT
subcategory_raw as name,
subcategory_raw as slug,
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE is_in_stock = true) as in_stock_count
FROM store_products
WHERE category_raw = $1
AND subcategory_raw IS NOT NULL
`;
const params: any[] = [category];
if (store_id) {
params.push(store_id);
query += ` AND dispensary_id = $${params.length}`;
}
if (in_stock_only === 'true') {
query += ` AND is_in_stock = true`;
}
query += `
GROUP BY subcategory_raw
ORDER BY subcategory_raw
`;
const result = await pool.query(query, params);
res.json({
category,
subcategories: result.rows
});
} catch (error) {
console.error('Error fetching subcategories:', error);
res.status(500).json({ error: 'Failed to fetch subcategories' });
}
});
// Get global category summary (across all stores)
router.get('/summary', async (req, res) => {
try {
const { state } = req.query;
let query = `
SELECT
sp.category_raw as category,
COUNT(DISTINCT sp.id) as product_count,
COUNT(DISTINCT sp.dispensary_id) as store_count,
COUNT(*) FILTER (WHERE sp.is_in_stock = true) as in_stock_count
FROM store_products sp
`;
const params: any[] = [];
if (state) {
query += `
JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.category_raw IS NOT NULL
AND d.state = $1
`;
params.push(state);
} else {
query += ` WHERE sp.category_raw IS NOT NULL`;
}
query += `
GROUP BY sp.category_raw
ORDER BY product_count DESC
`;
const result = await pool.query(query, params);
res.json({
categories: result.rows,
total_categories: result.rows.length
});
} catch (error) {
console.error('Error fetching category summary:', error);
res.status(500).json({ error: 'Failed to fetch category summary' });
}
});
export default router;

View File

@@ -11,7 +11,7 @@ const VALID_MENU_TYPES = ['dutchie', 'treez', 'jane', 'weedmaps', 'leafly', 'mea
// Get all dispensaries (with pagination)
router.get('/', async (req, res) => {
try {
const { menu_type, city, state, crawl_enabled, dutchie_verified, limit, offset, search } = req.query;
const { menu_type, city, state, crawl_enabled, dutchie_verified, status, limit, offset, search } = req.query;
const pageLimit = Math.min(parseInt(limit as string) || 50, 500);
const pageOffset = parseInt(offset as string) || 0;
@@ -100,6 +100,12 @@ router.get('/', async (req, res) => {
}
}
// Filter by status (e.g., 'dropped', 'open', 'closed')
if (status) {
conditions.push(`status = $${params.length + 1}`);
params.push(status);
}
// Search filter (name, dba_name, city, company_name)
if (search) {
conditions.push(`(name ILIKE $${params.length + 1} OR dba_name ILIKE $${params.length + 1} OR city ILIKE $${params.length + 1})`);
@@ -161,6 +167,7 @@ router.get('/stats/crawl-status', async (req, res) => {
COUNT(*) FILTER (WHERE crawl_enabled = false OR crawl_enabled IS NULL) as disabled_count,
COUNT(*) FILTER (WHERE dutchie_verified = true) as verified_count,
COUNT(*) FILTER (WHERE dutchie_verified = false OR dutchie_verified IS NULL) as unverified_count,
COUNT(*) FILTER (WHERE status = 'dropped') as dropped_count,
COUNT(*) as total_count
FROM dispensaries
`;
@@ -190,6 +197,34 @@ router.get('/stats/crawl-status', async (req, res) => {
}
});
// Get dropped stores count (for dashboard alert)
router.get('/stats/dropped', async (req, res) => {
try {
const result = await pool.query(`
SELECT
COUNT(*) as dropped_count,
json_agg(json_build_object(
'id', id,
'name', name,
'city', city,
'state', state,
'dropped_at', updated_at
) ORDER BY updated_at DESC) FILTER (WHERE status = 'dropped') as dropped_stores
FROM dispensaries
WHERE status = 'dropped'
`);
const row = result.rows[0];
res.json({
dropped_count: parseInt(row.dropped_count) || 0,
dropped_stores: row.dropped_stores || []
});
} catch (error) {
console.error('Error fetching dropped stores:', error);
res.status(500).json({ error: 'Failed to fetch dropped stores' });
}
});
// Get single dispensary by slug or ID
router.get('/:slugOrId', async (req, res) => {
try {

View File

@@ -22,11 +22,17 @@ interface ProductClickEventPayload {
store_id?: string;
brand_id?: string;
campaign_id?: string;
dispensary_name?: string;
action: 'view' | 'open_store' | 'open_product' | 'compare' | 'other';
source: string;
page_type?: string; // Page where event occurred (e.g., StoreDetailPage, BrandsIntelligence)
url_path?: string; // URL path for debugging
occurred_at?: string;
// Visitor location (from frontend IP geolocation)
visitor_city?: string;
visitor_state?: string;
visitor_lat?: number;
visitor_lng?: number;
}
/**
@@ -77,13 +83,14 @@ router.post('/product-click', optionalAuthMiddleware, async (req: Request, res:
// Insert the event with enhanced fields
await pool.query(
`INSERT INTO product_click_events
(product_id, store_id, brand_id, campaign_id, action, source, user_id, ip_address, user_agent, occurred_at, event_type, page_type, url_path, device_type)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`,
(product_id, store_id, brand_id, campaign_id, dispensary_name, action, source, user_id, ip_address, user_agent, occurred_at, event_type, page_type, url_path, device_type, visitor_city, visitor_state, visitor_lat, visitor_lng)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)`,
[
payload.product_id,
payload.store_id || null,
payload.brand_id || null,
payload.campaign_id || null,
payload.dispensary_name || null,
payload.action,
payload.source,
userId,
@@ -93,7 +100,11 @@ router.post('/product-click', optionalAuthMiddleware, async (req: Request, res:
'product_click', // event_type
payload.page_type || null,
payload.url_path || null,
deviceType
deviceType,
payload.visitor_city || null,
payload.visitor_state || null,
payload.visitor_lat || null,
payload.visitor_lng || null
]
);

View File

@@ -27,8 +27,8 @@ router.get('/brands', async (req: Request, res: Response) => {
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
COUNT(DISTINCT d.id) as store_count,
COUNT(DISTINCT sp.id) as sku_count,
ROUND(AVG(sp.price_rec)::numeric, 2) FILTER (WHERE sp.price_rec > 0) as avg_price_rec,
ROUND(AVG(sp.price_med)::numeric, 2) FILTER (WHERE sp.price_med > 0) as avg_price_med
ROUND(AVG(sp.price_rec) FILTER (WHERE sp.price_rec > 0)::numeric, 2) as avg_price_rec,
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
FROM store_products sp
JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
@@ -154,10 +154,9 @@ router.get('/pricing', async (req: Request, res: Response) => {
SELECT
sp.category_raw as category,
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
MIN(sp.price_rec) as min_price,
MAX(sp.price_rec) as max_price,
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2)
FILTER (WHERE sp.price_rec > 0) as median_price,
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
COUNT(*) as product_count
FROM store_products sp
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
@@ -169,7 +168,7 @@ router.get('/pricing', async (req: Request, res: Response) => {
SELECT
d.state,
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
MIN(sp.price_rec) as min_price,
MAX(sp.price_rec) as max_price,
COUNT(DISTINCT sp.id) as product_count
FROM store_products sp

View File

@@ -1,11 +1,29 @@
import { Router } from 'express';
import { authMiddleware } from '../auth/middleware';
import { pool } from '../db/pool';
import { getImageUrl } from '../utils/minio';
const router = Router();
router.use(authMiddleware);
/**
* Convert local image path to proxy URL
* /images/products/... -> /img/products/...
*/
function getImageUrl(localPath: string): string {
if (!localPath) return '';
// If already a full URL, return as-is
if (localPath.startsWith('http')) return localPath;
// Convert /images/ path to /img/ proxy path
if (localPath.startsWith('/images/')) {
return '/img' + localPath.substring(7);
}
// Handle paths without leading slash
if (localPath.startsWith('images/')) {
return '/img/' + localPath.substring(7);
}
return '/img/' + localPath;
}
// Freshness threshold: data older than this is considered stale
const STALE_THRESHOLD_HOURS = 4;

View File

@@ -2,7 +2,7 @@ import { Router } from 'express';
import { authMiddleware, requireRole } from '../auth/middleware';
import { pool } from '../db/pool';
import { testProxy, addProxy, addProxiesFromList } from '../services/proxy';
import { createProxyTestJob, getProxyTestJob, getActiveProxyTestJob, cancelProxyTestJob } from '../services/proxyTestQueue';
import { createProxyTestJob, getProxyTestJob, getActiveProxyTestJob, cancelProxyTestJob, ProxyTestMode } from '../services/proxyTestQueue';
const router = Router();
router.use(authMiddleware);
@@ -11,9 +11,10 @@ router.use(authMiddleware);
router.get('/', async (req, res) => {
try {
const result = await pool.query(`
SELECT id, host, port, protocol, active, is_anonymous,
SELECT id, host, port, protocol, username, password, active, is_anonymous,
last_tested_at, test_result, response_time_ms, created_at,
city, state, country, country_code, location_updated_at
city, state, country, country_code, location_updated_at,
COALESCE(max_connections, 1) as max_connections
FROM proxies
ORDER BY created_at DESC
`);
@@ -166,13 +167,39 @@ router.post('/:id/test', requireRole('superadmin', 'admin'), async (req, res) =>
});
// Start proxy test job
// Query params: mode=all|failed|inactive, concurrency=10
router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) => {
try {
const jobId = await createProxyTestJob();
res.json({ jobId, message: 'Proxy test job started' });
} catch (error) {
const mode = (req.query.mode as ProxyTestMode) || 'all';
const concurrency = parseInt(req.query.concurrency as string) || 10;
// Validate mode
if (!['all', 'failed', 'inactive'].includes(mode)) {
return res.status(400).json({ error: 'Invalid mode. Use: all, failed, or inactive' });
}
// Validate concurrency (1-50)
if (concurrency < 1 || concurrency > 50) {
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
}
const { jobId, totalProxies } = await createProxyTestJob(mode, concurrency);
res.json({ jobId, total: totalProxies, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
} catch (error: any) {
console.error('Error starting proxy test job:', error);
res.status(500).json({ error: 'Failed to start proxy test job' });
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
}
});
// Convenience endpoint: Test only failed proxies
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
try {
const concurrency = parseInt(req.query.concurrency as string) || 10;
const { jobId, totalProxies } = await createProxyTestJob('failed', concurrency);
res.json({ jobId, total: totalProxies, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
} catch (error: any) {
console.error('Error starting failed proxy test:', error);
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
}
});
@@ -197,8 +224,8 @@ router.post('/test-job/:jobId/cancel', requireRole('superadmin', 'admin'), async
router.put('/:id', requireRole('superadmin', 'admin'), async (req, res) => {
try {
const { id } = req.params;
const { host, port, protocol, username, password, active } = req.body;
const { host, port, protocol, username, password, active, max_connections } = req.body;
const result = await pool.query(`
UPDATE proxies
SET host = COALESCE($1, host),
@@ -207,10 +234,11 @@ router.put('/:id', requireRole('superadmin', 'admin'), async (req, res) => {
username = COALESCE($4, username),
password = COALESCE($5, password),
active = COALESCE($6, active),
max_connections = COALESCE($7, max_connections),
updated_at = CURRENT_TIMESTAMP
WHERE id = $7
WHERE id = $8
RETURNING *
`, [host, port, protocol, username, password, active, id]);
`, [host, port, protocol, username, password, active, max_connections, id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Proxy not found' });

View File

@@ -130,6 +130,12 @@ const CONSUMER_TRUSTED_ORIGINS = [
'http://localhost:3002',
];
// Wildcard trusted origin patterns (*.domain.com)
const CONSUMER_TRUSTED_PATTERNS = [
/^https:\/\/([a-z0-9-]+\.)?cannaiq\.co$/,
/^https:\/\/([a-z0-9-]+\.)?cannabrands\.app$/,
];
// Trusted IPs for local development (bypass API key auth)
const TRUSTED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
@@ -150,8 +156,17 @@ function isConsumerTrustedRequest(req: Request): boolean {
return true;
}
const origin = req.headers.origin;
if (origin && CONSUMER_TRUSTED_ORIGINS.includes(origin)) {
return true;
if (origin) {
// Check exact matches
if (CONSUMER_TRUSTED_ORIGINS.includes(origin)) {
return true;
}
// Check wildcard patterns
for (const pattern of CONSUMER_TRUSTED_PATTERNS) {
if (pattern.test(origin)) {
return true;
}
}
}
const referer = req.headers.referer;
if (referer) {
@@ -160,6 +175,18 @@ function isConsumerTrustedRequest(req: Request): boolean {
return true;
}
}
// Check wildcard patterns against referer origin
try {
const refererUrl = new URL(referer);
const refererOrigin = refererUrl.origin;
for (const pattern of CONSUMER_TRUSTED_PATTERNS) {
if (pattern.test(refererOrigin)) {
return true;
}
}
} catch {
// Invalid referer URL, ignore
}
}
return false;
}
@@ -463,7 +490,7 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
// Filter by on special
if (on_special === 'true' || on_special === '1') {
whereClause += ` AND s.is_on_special = TRUE`;
whereClause += ` AND s.special = TRUE`;
}
// Search by name or brand
@@ -547,7 +574,7 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
const { rows: countRows } = await pool.query(`
SELECT COUNT(*) as total FROM store_products p
LEFT JOIN LATERAL (
SELECT rec_min_price_cents / 100.0 as price_rec, med_min_price_cents / 100.0 as price_med, special as is_on_special FROM v_product_snapshots
SELECT rec_min_price_cents / 100.0 as price_rec, med_min_price_cents / 100.0 as price_med, special FROM v_product_snapshots
WHERE store_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
@@ -1125,6 +1152,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
SELECT
d.id,
d.name,
d.slug,
d.address1,
d.address2,
d.city,
@@ -1179,6 +1207,7 @@ router.get('/dispensaries', async (req: PublicApiRequest, res: Response) => {
const transformedDispensaries = dispensaries.map((d) => ({
id: d.id,
name: d.name,
slug: d.slug || null,
address1: d.address1,
address2: d.address2,
city: d.city,
@@ -1876,7 +1905,7 @@ router.get('/stats', async (req: PublicApiRequest, res: Response) => {
SELECT
(SELECT COUNT(*) FROM store_products) as product_count,
(SELECT COUNT(DISTINCT brand_name_raw) FROM store_products WHERE brand_name_raw IS NOT NULL) as brand_count,
(SELECT COUNT(*) FROM dispensaries WHERE crawl_enabled = true AND product_count > 0) as dispensary_count
(SELECT COUNT(DISTINCT dispensary_id) FROM store_products) as dispensary_count
`);
const s = stats[0] || {};
@@ -1996,4 +2025,235 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
}
});
// ============================================================
// VISITOR TRACKING & GEOLOCATION
// ============================================================
import crypto from 'crypto';
import { GeoLocation, lookupIP } from '../services/ip2location';
/**
* Get location from IP using local IP2Location database
*/
function getLocationFromIP(ip: string): GeoLocation | null {
return lookupIP(ip);
}
/**
* Hash IP for privacy (we don't store raw IPs)
*/
function hashIP(ip: string): string {
return crypto.createHash('sha256').update(ip).digest('hex').substring(0, 16);
}
/**
* POST /api/v1/visitor/track
* Track visitor location for analytics
*
* Body:
* - domain: string (required) - 'findagram.co', 'findadispo.com', etc.
* - page_path: string (optional) - current page path
* - session_id: string (optional) - client-generated session ID
* - referrer: string (optional) - document.referrer
*
* Returns:
* - location: { city, state, lat, lng } for client use
*/
router.post('/visitor/track', async (req: Request, res: Response) => {
try {
const { domain, page_path, session_id, referrer } = req.body;
if (!domain) {
return res.status(400).json({ error: 'domain is required' });
}
// Get client IP
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ||
req.headers['x-real-ip'] as string ||
req.ip ||
req.socket.remoteAddress ||
'';
// Get location from IP (local database lookup)
const location = getLocationFromIP(clientIp);
// Store visit (with hashed IP for privacy)
await pool.query(`
INSERT INTO visitor_locations (
ip_hash, city, state, state_code, country, country_code,
latitude, longitude, domain, page_path, referrer, user_agent, session_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
`, [
hashIP(clientIp),
location?.city || null,
location?.state || null,
location?.stateCode || null,
location?.country || null,
location?.countryCode || null,
location?.lat || null,
location?.lng || null,
domain,
page_path || null,
referrer || null,
req.headers['user-agent'] || null,
session_id || null
]);
// Return location to client (for nearby dispensary feature)
res.json({
success: true,
location: location ? {
city: location.city,
state: location.state,
stateCode: location.stateCode,
lat: location.lat,
lng: location.lng
} : null
});
} catch (error: any) {
console.error('Visitor tracking error:', error);
// Don't fail the request - tracking is non-critical
res.json({
success: false,
location: null
});
}
});
/**
* GET /api/v1/visitor/location
* Get visitor location without tracking (just IP lookup)
*/
router.get('/visitor/location', (req: Request, res: Response) => {
try {
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ||
req.headers['x-real-ip'] as string ||
req.ip ||
req.socket.remoteAddress ||
'';
const location = getLocationFromIP(clientIp);
res.json({
success: true,
location: location ? {
city: location.city,
state: location.state,
stateCode: location.stateCode,
lat: location.lat,
lng: location.lng
} : null
});
} catch (error: any) {
console.error('Location lookup error:', error);
res.json({
success: false,
location: null
});
}
});
/**
* GET /api/v1/analytics/visitors
* Get visitor analytics (admin only - requires auth)
*
* Query params:
* - domain: filter by domain
* - days: number of days to look back (default: 30)
* - limit: max results (default: 50)
*/
router.get('/analytics/visitors', async (req: PublicApiRequest, res: Response) => {
try {
const scope = req.scope;
// Only allow internal keys
if (!scope || scope.type !== 'internal') {
return res.status(403).json({ error: 'Access denied - internal key required' });
}
const { domain, days = '30', limit = '50' } = req.query;
const daysNum = Math.min(parseInt(days as string, 10) || 30, 90);
const limitNum = Math.min(parseInt(limit as string, 10) || 50, 200);
let whereClause = 'WHERE created_at > NOW() - $1::interval';
const params: any[] = [`${daysNum} days`];
let paramIndex = 2;
if (domain) {
whereClause += ` AND domain = $${paramIndex}`;
params.push(domain);
paramIndex++;
}
// Get top locations
const { rows: topLocations } = await pool.query(`
SELECT
city,
state,
state_code,
country_code,
COUNT(*) as visit_count,
COUNT(DISTINCT session_id) as unique_sessions,
MAX(created_at) as last_visit
FROM visitor_locations
${whereClause}
GROUP BY city, state, state_code, country_code
ORDER BY visit_count DESC
LIMIT $${paramIndex}
`, [...params, limitNum]);
// Get daily totals
const { rows: dailyStats } = await pool.query(`
SELECT
DATE(created_at) as date,
COUNT(*) as visits,
COUNT(DISTINCT session_id) as unique_sessions
FROM visitor_locations
${whereClause}
GROUP BY DATE(created_at)
ORDER BY date DESC
LIMIT 30
`, params);
// Get totals
const { rows: totals } = await pool.query(`
SELECT
COUNT(*) as total_visits,
COUNT(DISTINCT session_id) as total_sessions,
COUNT(DISTINCT city || state_code) as unique_locations
FROM visitor_locations
${whereClause}
`, params);
res.json({
success: true,
period: {
days: daysNum,
domain: domain || 'all'
},
totals: totals[0],
top_locations: topLocations.map(l => ({
city: l.city,
state: l.state,
state_code: l.state_code,
country_code: l.country_code,
visits: parseInt(l.visit_count, 10),
unique_sessions: parseInt(l.unique_sessions, 10),
last_visit: l.last_visit
})),
daily_stats: dailyStats.map(d => ({
date: d.date,
visits: parseInt(d.visits, 10),
unique_sessions: parseInt(d.unique_sessions, 10)
}))
});
} catch (error: any) {
console.error('Visitor analytics error:', error);
res.status(500).json({
error: 'Failed to fetch visitor analytics',
message: error.message
});
}
});
export default router;

View File

@@ -145,6 +145,36 @@ router.get('/:id', async (req: Request, res: Response) => {
}
});
/**
* DELETE /api/tasks/:id
* Delete a specific task by ID
* Only allows deletion of failed, completed, or pending tasks (not running)
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const taskId = parseInt(req.params.id, 10);
// First check if task exists and its status
const task = await taskService.getTask(taskId);
if (!task) {
return res.status(404).json({ error: 'Task not found' });
}
// Don't allow deleting running tasks
if (task.status === 'running' || task.status === 'claimed') {
return res.status(400).json({ error: 'Cannot delete a running or claimed task' });
}
// Delete the task
await pool.query('DELETE FROM worker_tasks WHERE id = $1', [taskId]);
res.json({ success: true, message: `Task ${taskId} deleted` });
} catch (error: unknown) {
console.error('Error deleting task:', error);
res.status(500).json({ error: 'Failed to delete task' });
}
});
/**
* POST /api/tasks
* Create a new task
@@ -444,7 +474,7 @@ router.post('/migration/cancel-pending-crawl-jobs', async (_req: Request, res: R
/**
* POST /api/tasks/migration/create-resync-tasks
* Create product_resync tasks for all crawl-enabled dispensaries
* Create product_refresh tasks for all crawl-enabled dispensaries
*/
router.post('/migration/create-resync-tasks', async (req: Request, res: Response) => {
try {
@@ -474,7 +504,7 @@ router.post('/migration/create-resync-tasks', async (req: Request, res: Response
const hasActive = await taskService.hasActiveTask(disp.id);
if (!hasActive) {
await taskService.createTask({
role: 'product_resync',
role: 'product_refresh',
dispensary_id: disp.id,
platform: 'dutchie',
priority,

View File

@@ -14,23 +14,36 @@ router.get('/', async (req: AuthRequest, res) => {
try {
const { search, domain } = req.query;
let query = `
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
FROM users
WHERE 1=1
`;
// Check which columns exist (schema-tolerant)
const columnsResult = await pool.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
// Build column list based on what exists
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
const params: any[] = [];
let paramIndex = 1;
// Search by email, first_name, or last_name
// Search by email (and optionally first_name, last_name if they exist)
if (search && typeof search === 'string') {
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`;
const searchClauses = ['email ILIKE $' + paramIndex];
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
query += ` AND (${searchClauses.join(' OR ')})`;
params.push(`%${search}%`);
paramIndex++;
}
// Filter by domain
if (domain && typeof domain === 'string') {
// Filter by domain (if column exists)
if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
query += ` AND domain = $${paramIndex}`;
params.push(domain);
paramIndex++;
@@ -50,8 +63,22 @@ router.get('/', async (req: AuthRequest, res) => {
router.get('/:id', async (req: AuthRequest, res) => {
try {
const { id } = req.params;
// Check which columns exist (schema-tolerant)
const columnsResult = await pool.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
`);
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
if (existingColumns.has('first_name')) selectCols.push('first_name');
if (existingColumns.has('last_name')) selectCols.push('last_name');
if (existingColumns.has('phone')) selectCols.push('phone');
if (existingColumns.has('domain')) selectCols.push('domain');
const result = await pool.query(`
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
SELECT ${selectCols.join(', ')}
FROM users
WHERE id = $1
`, [id]);

View File

@@ -0,0 +1,675 @@
/**
* Worker Registry API Routes
*
* Dynamic worker management - workers register on startup, get assigned names,
* and report heartbeats. Everything is API-driven, no hardcoding.
*
* Endpoints:
* POST /api/worker-registry/register - Worker reports for duty
* POST /api/worker-registry/heartbeat - Worker heartbeat
* POST /api/worker-registry/deregister - Worker signing off
* GET /api/worker-registry/workers - List all workers (for dashboard)
* GET /api/worker-registry/workers/:id - Get specific worker
* POST /api/worker-registry/cleanup - Mark stale workers offline
*
* GET /api/worker-registry/names - List all names in pool
* POST /api/worker-registry/names - Add names to pool
* DELETE /api/worker-registry/names/:name - Remove name from pool
*
* GET /api/worker-registry/roles - List available task roles
* POST /api/worker-registry/roles - Add a new role (future)
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import os from 'os';
const router = Router();
// ============================================================
// WORKER REGISTRATION
// ============================================================
/**
* POST /api/worker-registry/register
* Worker reports for duty - gets assigned a friendly name
*
* Body:
* - role: string (optional) - task role, or null for role-agnostic workers
* - worker_id: string (optional) - custom ID, auto-generated if not provided
* - pod_name: string (optional) - k8s pod name
* - hostname: string (optional) - machine hostname
* - metadata: object (optional) - additional worker info
*
* Returns:
* - worker_id: assigned worker ID
* - friendly_name: assigned name from pool
* - role: confirmed role (or null if agnostic)
* - message: welcome message
*/
router.post('/register', async (req: Request, res: Response) => {
try {
const {
role = null, // Role is now optional - null means agnostic
worker_id,
pod_name,
hostname,
ip_address,
metadata = {}
} = req.body;
// Generate worker_id if not provided
const finalWorkerId = worker_id || `worker-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const finalHostname = hostname || os.hostname();
const clientIp = ip_address || req.ip || req.socket.remoteAddress;
// Check if worker already registered
const existing = await pool.query(
'SELECT id, friendly_name, status FROM worker_registry WHERE worker_id = $1',
[finalWorkerId]
);
if (existing.rows.length > 0) {
// Re-activate existing worker
const { rows } = await pool.query(`
UPDATE worker_registry
SET status = 'active',
role = $1,
pod_name = $2,
hostname = $3,
ip_address = $4,
last_heartbeat_at = NOW(),
started_at = NOW(),
metadata = $5,
updated_at = NOW()
WHERE worker_id = $6
RETURNING id, worker_id, friendly_name, role
`, [role, pod_name, finalHostname, clientIp, metadata, finalWorkerId]);
const worker = rows[0];
const roleMsg = role ? `for ${role}` : 'as role-agnostic';
console.log(`[WorkerRegistry] Worker "${worker.friendly_name}" (${finalWorkerId}) re-registered ${roleMsg}`);
return res.json({
success: true,
worker_id: worker.worker_id,
friendly_name: worker.friendly_name,
role: worker.role,
message: role
? `Welcome back, ${worker.friendly_name}! You are assigned to ${role}.`
: `Welcome back, ${worker.friendly_name}! You are ready to take any task.`
});
}
// Assign a friendly name
const nameResult = await pool.query('SELECT assign_worker_name($1) as name', [finalWorkerId]);
const friendlyName = nameResult.rows[0].name;
// Register the worker
const { rows } = await pool.query(`
INSERT INTO worker_registry (
worker_id, friendly_name, role, pod_name, hostname, ip_address, status, metadata
) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7)
RETURNING id, worker_id, friendly_name, role
`, [finalWorkerId, friendlyName, role, pod_name, finalHostname, clientIp, metadata]);
const worker = rows[0];
const roleMsg = role ? `for ${role}` : 'as role-agnostic';
console.log(`[WorkerRegistry] New worker "${friendlyName}" (${finalWorkerId}) reporting for duty ${roleMsg}`);
res.json({
success: true,
worker_id: worker.worker_id,
friendly_name: worker.friendly_name,
role: worker.role,
message: role
? `Hello ${friendlyName}! You are now registered for ${role}. Ready for work!`
: `Hello ${friendlyName}! You are ready to take any task from the pool.`
});
} catch (error: any) {
console.error('[WorkerRegistry] Registration error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/worker-registry/heartbeat
* Worker sends heartbeat to stay alive
*
* Body:
* - worker_id: string (required)
* - current_task_id: number (optional) - task currently being processed
* - status: string (optional) - 'active', 'idle'
*/
router.post('/heartbeat', async (req: Request, res: Response) => {
try {
const { worker_id, current_task_id, status = 'active', resources } = req.body;
if (!worker_id) {
return res.status(400).json({ success: false, error: 'worker_id is required' });
}
// Store resources in metadata jsonb column
const { rows } = await pool.query(`
UPDATE worker_registry
SET last_heartbeat_at = NOW(),
current_task_id = $1,
status = $2,
metadata = COALESCE(metadata, '{}'::jsonb) || COALESCE($4::jsonb, '{}'::jsonb),
updated_at = NOW()
WHERE worker_id = $3
RETURNING id, friendly_name, status
`, [current_task_id || null, status, worker_id, resources ? JSON.stringify(resources) : null]);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found - please register first' });
}
res.json({
success: true,
worker: rows[0]
});
} catch (error: any) {
console.error('[WorkerRegistry] Heartbeat error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/worker-registry/task-completed
* Worker reports task completion
*
* Body:
* - worker_id: string (required)
* - success: boolean (required)
*/
router.post('/task-completed', async (req: Request, res: Response) => {
try {
const { worker_id, success } = req.body;
if (!worker_id) {
return res.status(400).json({ success: false, error: 'worker_id is required' });
}
const incrementField = success ? 'tasks_completed' : 'tasks_failed';
const { rows } = await pool.query(`
UPDATE worker_registry
SET ${incrementField} = ${incrementField} + 1,
last_task_at = NOW(),
current_task_id = NULL,
status = 'idle',
updated_at = NOW()
WHERE worker_id = $1
RETURNING id, friendly_name, tasks_completed, tasks_failed
`, [worker_id]);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
res.json({ success: true, worker: rows[0] });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/worker-registry/deregister
* Worker signing off (graceful shutdown)
*
* Body:
* - worker_id: string (required)
*/
router.post('/deregister', async (req: Request, res: Response) => {
try {
const { worker_id } = req.body;
if (!worker_id) {
return res.status(400).json({ success: false, error: 'worker_id is required' });
}
// Release the name back to the pool
await pool.query('SELECT release_worker_name($1)', [worker_id]);
// Mark as terminated
const { rows } = await pool.query(`
UPDATE worker_registry
SET status = 'terminated',
current_task_id = NULL,
updated_at = NOW()
WHERE worker_id = $1
RETURNING id, friendly_name
`, [worker_id]);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
console.log(`[WorkerRegistry] Worker "${rows[0].friendly_name}" (${worker_id}) signed off`);
res.json({
success: true,
message: `Goodbye ${rows[0].friendly_name}! Thanks for your work.`
});
} catch (error: any) {
console.error('[WorkerRegistry] Deregister error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// WORKER LISTING (for Dashboard)
// ============================================================
/**
* GET /api/worker-registry/workers
* List all workers (for dashboard)
*
* Query params:
* - status: filter by status (active, idle, offline, all)
* - role: filter by role
* - include_terminated: include terminated workers (default: false)
*/
router.get('/workers', async (req: Request, res: Response) => {
try {
// Check if worker_registry table exists
const tableCheck = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'worker_registry'
) as exists
`);
if (!tableCheck.rows[0].exists) {
// Return empty result if table doesn't exist yet
return res.json({
success: true,
workers: [],
summary: {
active_count: 0,
idle_count: 0,
offline_count: 0,
total_count: 0,
active_roles: 0
}
});
}
const { status, role, include_terminated = 'false' } = req.query;
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";
const params: any[] = [];
let paramIndex = 1;
if (status && status !== 'all') {
whereClause += ` AND status = $${paramIndex}`;
params.push(status);
paramIndex++;
}
if (role) {
whereClause += ` AND role = $${paramIndex}`;
params.push(role);
paramIndex++;
}
const { rows } = await pool.query(`
SELECT
id,
worker_id,
friendly_name,
role,
pod_name,
hostname,
ip_address,
status,
started_at,
last_heartbeat_at,
last_task_at,
tasks_completed,
tasks_failed,
current_task_id,
metadata,
EXTRACT(EPOCH FROM (NOW() - last_heartbeat_at)) as seconds_since_heartbeat,
CASE
WHEN status = 'offline' OR status = 'terminated' THEN status
WHEN last_heartbeat_at < NOW() - INTERVAL '2 minutes' THEN 'stale'
WHEN current_task_id IS NOT NULL THEN 'busy'
ELSE 'ready'
END as health_status,
created_at
FROM worker_registry
${whereClause}
ORDER BY
CASE status
WHEN 'active' THEN 1
WHEN 'idle' THEN 2
WHEN 'offline' THEN 3
ELSE 4
END,
last_heartbeat_at DESC
`, params);
// Get summary counts
const { rows: summary } = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'active') as active_count,
COUNT(*) FILTER (WHERE status = 'idle') as idle_count,
COUNT(*) FILTER (WHERE status = 'offline') as offline_count,
COUNT(*) FILTER (WHERE status != 'terminated') as total_count,
COUNT(DISTINCT role) FILTER (WHERE status IN ('active', 'idle')) as active_roles
FROM worker_registry
`);
res.json({
success: true,
workers: rows,
summary: summary[0]
});
} catch (error: any) {
console.error('[WorkerRegistry] List workers error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/worker-registry/workers/:workerId
* Get specific worker details
*/
router.get('/workers/:workerId', async (req: Request, res: Response) => {
try {
const { workerId } = req.params;
const { rows } = await pool.query(`
SELECT * FROM worker_registry WHERE worker_id = $1
`, [workerId]);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
res.json({ success: true, worker: rows[0] });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* DELETE /api/worker-registry/workers/:workerId
* Remove a worker (admin action)
*/
router.delete('/workers/:workerId', async (req: Request, res: Response) => {
try {
const { workerId } = req.params;
// Release name
await pool.query('SELECT release_worker_name($1)', [workerId]);
// Delete worker
const { rows } = await pool.query(`
DELETE FROM worker_registry WHERE worker_id = $1 RETURNING friendly_name
`, [workerId]);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
res.json({ success: true, message: `Worker ${rows[0].friendly_name} removed` });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/worker-registry/cleanup
* Mark stale workers as offline
*
* Body:
* - stale_threshold_minutes: number (default: 5)
*/
router.post('/cleanup', async (req: Request, res: Response) => {
try {
const { stale_threshold_minutes = 5 } = req.body;
const { rows } = await pool.query(
'SELECT mark_stale_workers($1) as count',
[stale_threshold_minutes]
);
res.json({
success: true,
stale_workers_marked: rows[0].count,
message: `Marked ${rows[0].count} stale workers as offline`
});
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// NAME POOL MANAGEMENT
// ============================================================
/**
* GET /api/worker-registry/names
* List all names in the pool
*/
router.get('/names', async (_req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT
id,
name,
in_use,
assigned_to,
assigned_at
FROM worker_name_pool
ORDER BY in_use DESC, name ASC
`);
const { rows: summary } = await pool.query(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE in_use = true) as in_use,
COUNT(*) FILTER (WHERE in_use = false) as available
FROM worker_name_pool
`);
res.json({
success: true,
names: rows,
summary: summary[0]
});
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/worker-registry/names
* Add names to the pool
*
* Body:
* - names: string[] (required) - array of names to add
*/
router.post('/names', async (req: Request, res: Response) => {
try {
const { names } = req.body;
if (!names || !Array.isArray(names) || names.length === 0) {
return res.status(400).json({ success: false, error: 'names array is required' });
}
const values = names.map(n => `('${n.replace(/'/g, "''")}')`).join(', ');
const { rowCount } = await pool.query(`
INSERT INTO worker_name_pool (name)
VALUES ${values}
ON CONFLICT (name) DO NOTHING
`);
res.json({
success: true,
added: rowCount,
message: `Added ${rowCount} new names to the pool`
});
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* DELETE /api/worker-registry/names/:name
* Remove a name from the pool (only if not in use)
*/
router.delete('/names/:name', async (req: Request, res: Response) => {
try {
const { name } = req.params;
const { rows } = await pool.query(`
DELETE FROM worker_name_pool
WHERE name = $1 AND in_use = false
RETURNING name
`, [name]);
if (rows.length === 0) {
return res.status(400).json({
success: false,
error: 'Name not found or currently in use'
});
}
res.json({ success: true, message: `Name "${name}" removed from pool` });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// ROLE MANAGEMENT
// ============================================================
/**
* GET /api/worker-registry/roles
* List available task roles
*/
router.get('/roles', async (_req: Request, res: Response) => {
// These are the roles the task handlers support
const roles = [
{
id: 'product_refresh',
name: 'Product Refresh',
description: 'Re-crawl dispensary products for price/stock changes',
handler: 'handleProductRefresh'
},
{
id: 'product_discovery',
name: 'Product Discovery',
description: 'Initial product discovery for new dispensaries',
handler: 'handleProductDiscovery'
},
{
id: 'store_discovery',
name: 'Store Discovery',
description: 'Discover new dispensary locations',
handler: 'handleStoreDiscovery'
},
{
id: 'entry_point_discovery',
name: 'Entry Point Discovery',
description: 'Resolve platform IDs from menu URLs',
handler: 'handleEntryPointDiscovery'
},
{
id: 'analytics_refresh',
name: 'Analytics Refresh',
description: 'Refresh materialized views and analytics',
handler: 'handleAnalyticsRefresh'
}
];
// Get active worker counts per role
try {
const { rows } = await pool.query(`
SELECT role, COUNT(*) as worker_count
FROM worker_registry
WHERE status IN ('active', 'idle')
GROUP BY role
`);
const countMap = new Map(rows.map(r => [r.role, parseInt(r.worker_count)]));
const rolesWithCounts = roles.map(r => ({
...r,
active_workers: countMap.get(r.id) || 0
}));
res.json({ success: true, roles: rolesWithCounts });
} catch {
// If table doesn't exist yet, just return roles without counts
res.json({ success: true, roles: roles.map(r => ({ ...r, active_workers: 0 })) });
}
});
/**
* GET /api/worker-registry/capacity
* Get capacity planning info
*/
router.get('/capacity', async (_req: Request, res: Response) => {
try {
// Get worker counts by role
const { rows: workerCounts } = await pool.query(`
SELECT role, COUNT(*) as count
FROM worker_registry
WHERE status IN ('active', 'idle')
GROUP BY role
`);
// Get pending task counts by role (if worker_tasks exists)
let taskCounts: any[] = [];
try {
const result = await pool.query(`
SELECT role, COUNT(*) as pending_count
FROM worker_tasks
WHERE status = 'pending'
GROUP BY role
`);
taskCounts = result.rows;
} catch {
// worker_tasks might not exist yet
}
// Get crawl-enabled store count
const storeCountResult = await pool.query(`
SELECT COUNT(*) as count
FROM dispensaries
WHERE crawl_enabled = true AND platform_dispensary_id IS NOT NULL
`);
const totalStores = parseInt(storeCountResult.rows[0].count);
const workerMap = new Map(workerCounts.map(r => [r.role, parseInt(r.count)]));
const taskMap = new Map(taskCounts.map(r => [r.role, parseInt(r.pending_count)]));
const roles = ['product_refresh', 'product_discovery', 'store_discovery', 'entry_point_discovery', 'analytics_refresh'];
const capacity = roles.map(role => ({
role,
active_workers: workerMap.get(role) || 0,
pending_tasks: taskMap.get(role) || 0,
// Rough estimate: 20 seconds per task, 4-hour cycle
tasks_per_worker_per_cycle: 720,
workers_needed_for_all_stores: Math.ceil(totalStores / 720)
}));
res.json({
success: true,
total_stores: totalStores,
capacity
});
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
export default router;

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,8 @@ import {
PenetrationDataPoint,
BrandMarketPosition,
BrandRecVsMedFootprint,
BrandPromotionalSummary,
BrandPromotionalEvent,
} from './types';
export class BrandPenetrationService {
@@ -44,16 +46,17 @@ export class BrandPenetrationService {
// Get current brand presence
const currentResult = await this.pool.query(`
SELECT
sp.brand_name,
sp.brand_name_raw AS brand_name,
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries,
COUNT(*) AS total_skus,
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus_per_dispensary,
ARRAY_AGG(DISTINCT s.code) FILTER (WHERE s.code IS NOT NULL) AS states_present
FROM store_products sp
LEFT JOIN states s ON s.id = sp.state_id
WHERE sp.brand_name = $1
JOIN dispensaries d ON d.id = sp.dispensary_id
LEFT JOIN states s ON s.id = d.state_id
WHERE sp.brand_name_raw = $1
AND sp.is_in_stock = TRUE
GROUP BY sp.brand_name
GROUP BY sp.brand_name_raw
`, [brandName]);
if (currentResult.rows.length === 0) {
@@ -72,7 +75,7 @@ export class BrandPenetrationService {
DATE(sps.captured_at) AS date,
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
FROM store_product_snapshots sps
WHERE sps.brand_name = $1
WHERE sps.brand_name_raw = $1
AND sps.captured_at >= $2
AND sps.captured_at <= $3
AND sps.is_in_stock = TRUE
@@ -123,8 +126,9 @@ export class BrandPenetrationService {
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
COUNT(*) AS sku_count
FROM store_products sp
JOIN states s ON s.id = sp.state_id
WHERE sp.brand_name = $1
JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE sp.brand_name_raw = $1
AND sp.is_in_stock = TRUE
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
),
@@ -133,7 +137,8 @@ export class BrandPenetrationService {
s.code AS state_code,
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries
FROM store_products sp
JOIN states s ON s.id = sp.state_id
JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE sp.is_in_stock = TRUE
GROUP BY s.code
)
@@ -169,7 +174,7 @@ export class BrandPenetrationService {
let filters = '';
if (options.category) {
filters += ` AND sp.category = $${paramIdx}`;
filters += ` AND sp.category_raw = $${paramIdx}`;
params.push(options.category);
paramIdx++;
}
@@ -183,31 +188,33 @@ export class BrandPenetrationService {
const result = await this.pool.query(`
WITH brand_metrics AS (
SELECT
sp.brand_name,
sp.category,
sp.brand_name_raw AS brand_name,
sp.category_raw AS category,
s.code AS state_code,
COUNT(*) AS sku_count,
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
AVG(sp.price_rec) AS avg_price
FROM store_products sp
JOIN states s ON s.id = sp.state_id
WHERE sp.brand_name = $1
JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE sp.brand_name_raw = $1
AND sp.is_in_stock = TRUE
AND sp.category IS NOT NULL
AND sp.category_raw IS NOT NULL
${filters}
GROUP BY sp.brand_name, sp.category, s.code
GROUP BY sp.brand_name_raw, sp.category_raw, s.code
),
category_totals AS (
SELECT
sp.category,
sp.category_raw AS category,
s.code AS state_code,
COUNT(*) AS total_skus,
AVG(sp.price_rec) AS category_avg_price
FROM store_products sp
JOIN states s ON s.id = sp.state_id
JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE sp.is_in_stock = TRUE
AND sp.category IS NOT NULL
GROUP BY sp.category, s.code
AND sp.category_raw IS NOT NULL
GROUP BY sp.category_raw, s.code
)
SELECT
bm.*,
@@ -243,8 +250,9 @@ export class BrandPenetrationService {
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
FROM store_products sp
JOIN states s ON s.id = sp.state_id
WHERE sp.brand_name = $1
JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE sp.brand_name_raw = $1
AND sp.is_in_stock = TRUE
AND s.recreational_legal = TRUE
),
@@ -255,8 +263,9 @@ export class BrandPenetrationService {
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
FROM store_products sp
JOIN states s ON s.id = sp.state_id
WHERE sp.brand_name = $1
JOIN dispensaries d ON d.id = sp.dispensary_id
JOIN states s ON s.id = d.state_id
WHERE sp.brand_name_raw = $1
AND sp.is_in_stock = TRUE
AND s.medical_legal = TRUE
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
@@ -311,23 +320,24 @@ export class BrandPenetrationService {
}
if (category) {
filters += ` AND sp.category = $${paramIdx}`;
filters += ` AND sp.category_raw = $${paramIdx}`;
params.push(category);
paramIdx++;
}
const result = await this.pool.query(`
SELECT
sp.brand_name,
sp.brand_name_raw AS brand_name,
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
COUNT(*) AS sku_count,
COUNT(DISTINCT s.code) AS state_count
FROM store_products sp
LEFT JOIN states s ON s.id = sp.state_id
WHERE sp.brand_name IS NOT NULL
JOIN dispensaries d ON d.id = sp.dispensary_id
LEFT JOIN states s ON s.id = d.state_id
WHERE sp.brand_name_raw IS NOT NULL
AND sp.is_in_stock = TRUE
${filters}
GROUP BY sp.brand_name
GROUP BY sp.brand_name_raw
ORDER BY dispensary_count DESC, sku_count DESC
LIMIT $1
`, params);
@@ -358,23 +368,23 @@ export class BrandPenetrationService {
const result = await this.pool.query(`
WITH start_counts AS (
SELECT
brand_name,
brand_name_raw AS brand_name,
COUNT(DISTINCT dispensary_id) AS dispensary_count
FROM store_product_snapshots
WHERE captured_at >= $1 AND captured_at < $1 + INTERVAL '1 day'
AND brand_name IS NOT NULL
AND brand_name_raw IS NOT NULL
AND is_in_stock = TRUE
GROUP BY brand_name
GROUP BY brand_name_raw
),
end_counts AS (
SELECT
brand_name,
brand_name_raw AS brand_name,
COUNT(DISTINCT dispensary_id) AS dispensary_count
FROM store_product_snapshots
WHERE captured_at >= $2 - INTERVAL '1 day' AND captured_at <= $2
AND brand_name IS NOT NULL
AND brand_name_raw IS NOT NULL
AND is_in_stock = TRUE
GROUP BY brand_name
GROUP BY brand_name_raw
)
SELECT
COALESCE(sc.brand_name, ec.brand_name) AS brand_name,
@@ -401,6 +411,225 @@ export class BrandPenetrationService {
change_percent: row.change_percent ? parseFloat(row.change_percent) : 0,
}));
}
/**
* Get brand promotional history
*
* Tracks when products went on special, how long, what discount,
* and estimated quantity sold during the promotion.
*/
async getBrandPromotionalHistory(
brandName: string,
options: { window?: TimeWindow; customRange?: DateRange; stateCode?: string; category?: string } = {}
): Promise<BrandPromotionalSummary> {
const { window = '90d', customRange, stateCode, category } = options;
const { start, end } = getDateRangeFromWindow(window, customRange);
// Build filters
const params: any[] = [brandName, start, end];
let paramIdx = 4;
let filters = '';
if (stateCode) {
filters += ` AND s.code = $${paramIdx}`;
params.push(stateCode);
paramIdx++;
}
if (category) {
filters += ` AND sp.category_raw = $${paramIdx}`;
params.push(category);
paramIdx++;
}
// Find promotional events by detecting when is_on_special transitions to TRUE
// and tracking until it transitions back to FALSE
const eventsResult = await this.pool.query(`
WITH snapshot_with_lag AS (
SELECT
sps.id,
sps.store_product_id,
sps.dispensary_id,
sps.brand_name_raw,
sps.name_raw,
sps.category_raw,
sps.is_on_special,
sps.price_rec,
sps.price_rec_special,
sps.stock_quantity,
sps.captured_at,
LAG(sps.is_on_special) OVER (
PARTITION BY sps.store_product_id
ORDER BY sps.captured_at
) AS prev_is_on_special,
LAG(sps.stock_quantity) OVER (
PARTITION BY sps.store_product_id
ORDER BY sps.captured_at
) AS prev_stock_quantity
FROM store_product_snapshots sps
JOIN store_products sp ON sp.id = sps.store_product_id
JOIN dispensaries dd ON dd.id = sp.dispensary_id
LEFT JOIN states s ON s.id = dd.state_id
WHERE sps.brand_name_raw = $1
AND sps.captured_at >= $2
AND sps.captured_at <= $3
${filters}
),
special_starts AS (
-- Find when specials START (transition from not-on-special to on-special)
SELECT
store_product_id,
dispensary_id,
name_raw,
category_raw,
captured_at AS special_start,
price_rec AS regular_price,
price_rec_special AS special_price,
stock_quantity AS quantity_at_start
FROM snapshot_with_lag
WHERE is_on_special = TRUE
AND (prev_is_on_special = FALSE OR prev_is_on_special IS NULL)
AND price_rec_special IS NOT NULL
AND price_rec IS NOT NULL
),
special_ends AS (
-- Find when specials END (transition from on-special to not-on-special)
SELECT
store_product_id,
captured_at AS special_end,
prev_stock_quantity AS quantity_at_end
FROM snapshot_with_lag
WHERE is_on_special = FALSE
AND prev_is_on_special = TRUE
),
matched_events AS (
SELECT
ss.store_product_id,
ss.dispensary_id,
ss.name_raw AS product_name,
ss.category_raw AS category,
ss.special_start,
se.special_end,
ss.regular_price,
ss.special_price,
ss.quantity_at_start,
COALESCE(se.quantity_at_end, ss.quantity_at_start) AS quantity_at_end
FROM special_starts ss
LEFT JOIN special_ends se ON se.store_product_id = ss.store_product_id
AND se.special_end > ss.special_start
AND se.special_end = (
SELECT MIN(se2.special_end)
FROM special_ends se2
WHERE se2.store_product_id = ss.store_product_id
AND se2.special_end > ss.special_start
)
)
SELECT
me.store_product_id,
me.dispensary_id,
d.name AS dispensary_name,
s.code AS state_code,
me.product_name,
me.category,
me.special_start,
me.special_end,
EXTRACT(DAY FROM COALESCE(me.special_end, NOW()) - me.special_start)::INT AS duration_days,
me.regular_price,
me.special_price,
ROUND(((me.regular_price - me.special_price) / NULLIF(me.regular_price, 0)) * 100, 1) AS discount_percent,
me.quantity_at_start,
me.quantity_at_end,
GREATEST(0, COALESCE(me.quantity_at_start, 0) - COALESCE(me.quantity_at_end, 0)) AS quantity_sold_estimate
FROM matched_events me
JOIN dispensaries d ON d.id = me.dispensary_id
LEFT JOIN states s ON s.id = d.state_id
ORDER BY me.special_start DESC
`, params);
const events: BrandPromotionalEvent[] = eventsResult.rows.map((row: any) => ({
product_name: row.product_name,
store_product_id: parseInt(row.store_product_id),
dispensary_id: parseInt(row.dispensary_id),
dispensary_name: row.dispensary_name,
state_code: row.state_code || 'Unknown',
category: row.category,
special_start: row.special_start.toISOString().split('T')[0],
special_end: row.special_end ? row.special_end.toISOString().split('T')[0] : null,
duration_days: row.duration_days ? parseInt(row.duration_days) : null,
regular_price: parseFloat(row.regular_price) || 0,
special_price: parseFloat(row.special_price) || 0,
discount_percent: parseFloat(row.discount_percent) || 0,
quantity_at_start: row.quantity_at_start ? parseInt(row.quantity_at_start) : null,
quantity_at_end: row.quantity_at_end ? parseInt(row.quantity_at_end) : null,
quantity_sold_estimate: row.quantity_sold_estimate ? parseInt(row.quantity_sold_estimate) : null,
}));
// Calculate summary stats
const totalEvents = events.length;
const uniqueProducts = new Set(events.map(e => e.store_product_id)).size;
const uniqueDispensaries = new Set(events.map(e => e.dispensary_id)).size;
const uniqueStates = [...new Set(events.map(e => e.state_code))];
const avgDiscount = totalEvents > 0
? events.reduce((sum, e) => sum + e.discount_percent, 0) / totalEvents
: 0;
const durations = events.filter(e => e.duration_days !== null).map(e => e.duration_days!);
const avgDuration = durations.length > 0
? durations.reduce((sum, d) => sum + d, 0) / durations.length
: null;
const totalQuantitySold = events
.filter(e => e.quantity_sold_estimate !== null)
.reduce((sum, e) => sum + (e.quantity_sold_estimate || 0), 0);
// Calculate frequency
const windowDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
const weeklyAvg = windowDays > 0 ? (totalEvents / windowDays) * 7 : 0;
const monthlyAvg = windowDays > 0 ? (totalEvents / windowDays) * 30 : 0;
// Group by category
const categoryMap = new Map<string, { count: number; discounts: number[]; quantity: number }>();
for (const event of events) {
const cat = event.category || 'Uncategorized';
if (!categoryMap.has(cat)) {
categoryMap.set(cat, { count: 0, discounts: [], quantity: 0 });
}
const entry = categoryMap.get(cat)!;
entry.count++;
entry.discounts.push(event.discount_percent);
if (event.quantity_sold_estimate !== null) {
entry.quantity += event.quantity_sold_estimate;
}
}
const byCategory = Array.from(categoryMap.entries()).map(([category, data]) => ({
category,
event_count: data.count,
avg_discount_percent: data.discounts.length > 0
? Math.round((data.discounts.reduce((a, b) => a + b, 0) / data.discounts.length) * 10) / 10
: 0,
quantity_sold_estimate: data.quantity > 0 ? data.quantity : null,
})).sort((a, b) => b.event_count - a.event_count);
return {
brand_name: brandName,
window,
total_promotional_events: totalEvents,
total_products_on_special: uniqueProducts,
total_dispensaries_with_specials: uniqueDispensaries,
states_with_specials: uniqueStates,
avg_discount_percent: Math.round(avgDiscount * 10) / 10,
avg_duration_days: avgDuration !== null ? Math.round(avgDuration * 10) / 10 : null,
total_quantity_sold_estimate: totalQuantitySold > 0 ? totalQuantitySold : null,
promotional_frequency: {
weekly_avg: Math.round(weeklyAvg * 10) / 10,
monthly_avg: Math.round(monthlyAvg * 10) / 10,
},
by_category: byCategory,
events,
};
}
}
export default BrandPenetrationService;

View File

@@ -259,6 +259,122 @@ export class StoreAnalyticsService {
}));
}
/**
* Get quantity changes for a store (increases/decreases)
* Useful for estimating sales (decreases) or restocks (increases)
*
* @param direction - 'decrease' for likely sales, 'increase' for restocks, 'all' for both
*/
async getQuantityChanges(
dispensaryId: number,
options: {
window?: TimeWindow;
customRange?: DateRange;
direction?: 'increase' | 'decrease' | 'all';
limit?: number;
} = {}
): Promise<{
dispensary_id: number;
window: TimeWindow;
direction: string;
total_changes: number;
total_units_decreased: number;
total_units_increased: number;
changes: Array<{
store_product_id: number;
product_name: string;
brand_name: string | null;
category: string | null;
old_quantity: number;
new_quantity: number;
quantity_delta: number;
direction: 'increase' | 'decrease';
captured_at: string;
}>;
}> {
const { window = '7d', customRange, direction = 'all', limit = 100 } = options;
const { start, end } = getDateRangeFromWindow(window, customRange);
// Build direction filter
let directionFilter = '';
if (direction === 'decrease') {
directionFilter = 'AND qty_delta < 0';
} else if (direction === 'increase') {
directionFilter = 'AND qty_delta > 0';
}
const result = await this.pool.query(`
WITH qty_changes AS (
SELECT
sps.store_product_id,
sp.name_raw AS product_name,
sp.brand_name_raw AS brand_name,
sp.category_raw AS category,
LAG(sps.stock_quantity) OVER w AS old_quantity,
sps.stock_quantity AS new_quantity,
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta,
sps.captured_at
FROM store_product_snapshots sps
JOIN store_products sp ON sp.id = sps.store_product_id
WHERE sps.dispensary_id = $1
AND sps.captured_at >= $2
AND sps.captured_at <= $3
AND sps.stock_quantity IS NOT NULL
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
)
SELECT *
FROM qty_changes
WHERE old_quantity IS NOT NULL
AND qty_delta != 0
${directionFilter}
ORDER BY captured_at DESC
LIMIT $4
`, [dispensaryId, start, end, limit]);
// Calculate totals
const totalsResult = await this.pool.query(`
WITH qty_changes AS (
SELECT
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta
FROM store_product_snapshots sps
WHERE sps.dispensary_id = $1
AND sps.captured_at >= $2
AND sps.captured_at <= $3
AND sps.stock_quantity IS NOT NULL
AND sps.store_product_id IS NOT NULL
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
)
SELECT
COUNT(*) FILTER (WHERE qty_delta != 0) AS total_changes,
COALESCE(SUM(ABS(qty_delta)) FILTER (WHERE qty_delta < 0), 0) AS units_decreased,
COALESCE(SUM(qty_delta) FILTER (WHERE qty_delta > 0), 0) AS units_increased
FROM qty_changes
WHERE qty_delta IS NOT NULL
`, [dispensaryId, start, end]);
const totals = totalsResult.rows[0] || {};
return {
dispensary_id: dispensaryId,
window,
direction,
total_changes: parseInt(totals.total_changes) || 0,
total_units_decreased: parseInt(totals.units_decreased) || 0,
total_units_increased: parseInt(totals.units_increased) || 0,
changes: result.rows.map((row: any) => ({
store_product_id: row.store_product_id,
product_name: row.product_name,
brand_name: row.brand_name,
category: row.category,
old_quantity: row.old_quantity,
new_quantity: row.new_quantity,
quantity_delta: row.qty_delta,
direction: row.qty_delta > 0 ? 'increase' : 'decrease',
captured_at: row.captured_at?.toISOString() || null,
})),
};
}
/**
* Get store inventory composition (categories and brands breakdown)
*/

View File

@@ -11,3 +11,4 @@ export { BrandPenetrationService } from './BrandPenetrationService';
export { CategoryAnalyticsService } from './CategoryAnalyticsService';
export { StoreAnalyticsService } from './StoreAnalyticsService';
export { StateAnalyticsService } from './StateAnalyticsService';
export { BrandIntelligenceService } from './BrandIntelligenceService';

View File

@@ -322,3 +322,48 @@ export interface RecVsMedPriceComparison {
};
price_diff_percent: number | null;
}
// ============================================================
// BRAND PROMOTIONAL ANALYTICS TYPES
// ============================================================
export interface BrandPromotionalEvent {
product_name: string;
store_product_id: number;
dispensary_id: number;
dispensary_name: string;
state_code: string;
category: string | null;
special_start: string; // ISO date when special started
special_end: string | null; // ISO date when special ended (null if ongoing)
duration_days: number | null;
regular_price: number;
special_price: number;
discount_percent: number;
quantity_at_start: number | null;
quantity_at_end: number | null;
quantity_sold_estimate: number | null; // quantity_at_start - quantity_at_end
}
export interface BrandPromotionalSummary {
brand_name: string;
window: TimeWindow;
total_promotional_events: number;
total_products_on_special: number;
total_dispensaries_with_specials: number;
states_with_specials: string[];
avg_discount_percent: number;
avg_duration_days: number | null;
total_quantity_sold_estimate: number | null;
promotional_frequency: {
weekly_avg: number;
monthly_avg: number;
};
by_category: Array<{
category: string;
event_count: number;
avg_discount_percent: number;
quantity_sold_estimate: number | null;
}>;
events: BrandPromotionalEvent[];
}

View File

@@ -61,6 +61,13 @@ export interface Proxy {
failureCount: number;
successCount: number;
avgResponseTimeMs: number | null;
maxConnections: number; // Number of concurrent connections allowed (for rotating proxies)
// Location info (if known)
city?: string;
state?: string;
country?: string;
countryCode?: string;
timezone?: string;
}
export interface ProxyStats {
@@ -109,18 +116,27 @@ export class ProxyRotator {
username,
password,
protocol,
is_active as "isActive",
last_used_at as "lastUsedAt",
active as "isActive",
last_tested_at as "lastUsedAt",
failure_count as "failureCount",
success_count as "successCount",
avg_response_time_ms as "avgResponseTimeMs"
0 as "successCount",
response_time_ms as "avgResponseTimeMs",
COALESCE(max_connections, 1) as "maxConnections",
city,
state,
country,
country_code as "countryCode",
timezone
FROM proxies
WHERE is_active = true
ORDER BY failure_count ASC, last_used_at ASC NULLS FIRST
WHERE active = true
ORDER BY failure_count ASC, last_tested_at ASC NULLS FIRST
`);
this.proxies = result.rows;
console.log(`[ProxyRotator] Loaded ${this.proxies.length} active proxies`);
// Calculate total concurrent capacity
const totalCapacity = this.proxies.reduce((sum, p) => sum + p.maxConnections, 0);
console.log(`[ProxyRotator] Loaded ${this.proxies.length} active proxies (${totalCapacity} max concurrent connections)`);
} catch (error) {
// Table might not exist - that's okay
console.warn(`[ProxyRotator] Could not load proxies: ${error}`);
@@ -192,11 +208,11 @@ export class ProxyRotator {
UPDATE proxies
SET
failure_count = failure_count + 1,
last_failure_at = NOW(),
last_error = $2,
is_active = CASE WHEN failure_count >= 4 THEN false ELSE is_active END
updated_at = NOW(),
test_result = $2,
active = CASE WHEN failure_count >= 4 THEN false ELSE active END
WHERE id = $1
`, [proxyId, error || null]);
`, [proxyId, error || 'failed']);
} catch (err) {
console.error(`[ProxyRotator] Failed to update proxy ${proxyId}:`, err);
}
@@ -226,12 +242,13 @@ export class ProxyRotator {
await this.pool.query(`
UPDATE proxies
SET
success_count = success_count + 1,
last_used_at = NOW(),
avg_response_time_ms = CASE
WHEN avg_response_time_ms IS NULL THEN $2
ELSE (avg_response_time_ms * 0.8) + ($2 * 0.2)
END
last_tested_at = NOW(),
test_result = 'success',
response_time_ms = CASE
WHEN response_time_ms IS NULL THEN $2
ELSE (response_time_ms * 0.8 + $2 * 0.2)::integer
END,
updated_at = NOW()
WHERE id = $1
`, [proxyId, responseTimeMs || null]);
} catch (err) {
@@ -255,7 +272,7 @@ export class ProxyRotator {
*/
getStats(): ProxyStats {
const totalProxies = this.proxies.length;
const activeProxies = this.proxies.filter(p => p.isActive).length;
const activeProxies = this.proxies.reduce((sum, p) => sum + p.maxConnections, 0); // Total concurrent capacity
const blockedProxies = this.proxies.filter(p => p.failureCount >= 5).length;
const successRates = this.proxies
@@ -268,7 +285,7 @@ export class ProxyRotator {
return {
totalProxies,
activeProxies,
activeProxies, // Total concurrent capacity across all proxies
blockedProxies,
avgSuccessRate,
};
@@ -402,6 +419,26 @@ export class CrawlRotator {
await this.proxy.markFailed(current.id, error);
}
}
/**
* Get current proxy location info (for reporting)
* Note: For rotating proxies (like IPRoyal), the actual exit location varies per request
*/
getProxyLocation(): { city?: string; state?: string; country?: string; timezone?: string; isRotating: boolean } | null {
const current = this.proxy.getCurrent();
if (!current) return null;
// Check if this is a rotating proxy (max_connections > 1 usually indicates rotating)
const isRotating = current.maxConnections > 1;
return {
city: current.city,
state: current.state,
country: current.country,
timezone: current.timezone,
isRotating
};
}
}
// ============================================================

View File

@@ -0,0 +1,134 @@
/**
* IP2Location Service
*
* Uses local IP2Location LITE DB3 database for IP geolocation.
* No external API calls, no rate limits.
*
* Database: IP2Location LITE DB3 (free, monthly updates)
* Fields: country, region, city, latitude, longitude
*/
import path from 'path';
import fs from 'fs';
// @ts-ignore - no types for ip2location-nodejs
const { IP2Location } = require('ip2location-nodejs');
const DB_PATH = process.env.IP2LOCATION_DB_PATH ||
path.join(__dirname, '../../data/ip2location/IP2LOCATION-LITE-DB5.BIN');
let ip2location: any = null;
let dbLoaded = false;
/**
* Initialize IP2Location database
*/
export function initIP2Location(): boolean {
if (dbLoaded) return true;
try {
if (!fs.existsSync(DB_PATH)) {
console.warn(`IP2Location database not found at: ${DB_PATH}`);
console.warn('Run: ./scripts/download-ip2location.sh to download');
return false;
}
ip2location = new IP2Location();
ip2location.open(DB_PATH);
dbLoaded = true;
console.log('IP2Location database loaded successfully');
return true;
} catch (err) {
console.error('Failed to load IP2Location database:', err);
return false;
}
}
/**
* Close IP2Location database
*/
export function closeIP2Location(): void {
if (ip2location) {
ip2location.close();
ip2location = null;
dbLoaded = false;
}
}
export interface GeoLocation {
city: string | null;
state: string | null;
stateCode: string | null;
country: string | null;
countryCode: string | null;
lat: number | null;
lng: number | null;
}
/**
* Lookup IP address location
*
* @param ip - IPv4 or IPv6 address
* @returns Location data or null if not found
*/
export function lookupIP(ip: string): GeoLocation | null {
// Skip private/localhost IPs
if (!ip || ip === '127.0.0.1' || ip === '::1' ||
ip.startsWith('192.168.') || ip.startsWith('10.') ||
ip.startsWith('172.16.') || ip.startsWith('172.17.') ||
ip.startsWith('::ffff:127.') || ip.startsWith('::ffff:192.168.') ||
ip.startsWith('::ffff:10.')) {
return null;
}
// Strip IPv6 prefix if present
const cleanIP = ip.replace(/^::ffff:/, '');
// Initialize on first use if not already loaded
if (!dbLoaded) {
if (!initIP2Location()) {
return null;
}
}
try {
const result = ip2location.getAll(cleanIP);
if (!result || result.ip === '?' || result.countryShort === '-') {
return null;
}
// DB3 LITE doesn't include lat/lng - would need DB5+ for that
const lat = typeof result.latitude === 'number' && result.latitude !== 0 ? result.latitude : null;
const lng = typeof result.longitude === 'number' && result.longitude !== 0 ? result.longitude : null;
return {
city: result.city !== '-' ? result.city : null,
state: result.region !== '-' ? result.region : null,
stateCode: null, // DB3 doesn't include state codes
country: result.countryLong !== '-' ? result.countryLong : null,
countryCode: result.countryShort !== '-' ? result.countryShort : null,
lat,
lng,
};
} catch (err) {
console.error('IP2Location lookup error:', err);
return null;
}
}
/**
* Check if IP2Location database is available
*/
export function isIP2LocationAvailable(): boolean {
if (dbLoaded) return true;
return fs.existsSync(DB_PATH);
}
// Export singleton-style interface
export default {
init: initIP2Location,
close: closeIP2Location,
lookup: lookupIP,
isAvailable: isIP2LocationAvailable,
};

View File

@@ -276,7 +276,6 @@ export async function addProxiesFromList(proxies: Array<{
await pool.query(`
INSERT INTO proxies (host, port, protocol, username, password, active)
VALUES ($1, $2, $3, $4, $5, false)
ON CONFLICT (host, port, protocol) DO NOTHING
`, [
proxy.host,
proxy.port,
@@ -285,27 +284,9 @@ export async function addProxiesFromList(proxies: Array<{
proxy.password
]);
// Check if it was actually inserted
const result = await pool.query(`
SELECT id FROM proxies
WHERE host = $1 AND port = $2 AND protocol = $3
`, [proxy.host, proxy.port, proxy.protocol]);
if (result.rows.length > 0) {
// Check if it was just inserted (no last_tested_at means new)
const checkResult = await pool.query(`
SELECT last_tested_at FROM proxies
WHERE host = $1 AND port = $2 AND protocol = $3
`, [proxy.host, proxy.port, proxy.protocol]);
if (checkResult.rows[0].last_tested_at === null) {
added++;
if (added % 100 === 0) {
console.log(`📥 Imported ${added} proxies...`);
}
} else {
duplicates++;
}
added++;
if (added % 100 === 0) {
console.log(`📥 Imported ${added} proxies...`);
}
} catch (error: any) {
failed++;

View File

@@ -8,8 +8,12 @@ interface ProxyTestJob {
tested_proxies: number;
passed_proxies: number;
failed_proxies: number;
mode?: string; // 'all' | 'failed' | 'inactive'
}
// Concurrency settings
const DEFAULT_CONCURRENCY = 10; // Test 10 proxies at a time
// Simple in-memory queue - could be replaced with Bull/Bee-Queue for production
const activeJobs = new Map<number, { cancelled: boolean }>();
@@ -33,18 +37,40 @@ export async function cleanupOrphanedJobs(): Promise<void> {
}
}
export async function createProxyTestJob(): Promise<number> {
export type ProxyTestMode = 'all' | 'failed' | 'inactive';
export interface CreateJobResult {
jobId: number;
totalProxies: number;
}
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<CreateJobResult> {
// Check for existing running jobs first
const existingJob = await getActiveProxyTestJob();
if (existingJob) {
throw new Error('A proxy test job is already running. Please cancel it first.');
}
const result = await pool.query(`
SELECT COUNT(*) as count FROM proxies
`);
// Get count based on mode
let countQuery: string;
switch (mode) {
case 'failed':
countQuery = `SELECT COUNT(*) as count FROM proxies WHERE test_result = 'failed' OR active = false`;
break;
case 'inactive':
countQuery = `SELECT COUNT(*) as count FROM proxies WHERE active = false`;
break;
default:
countQuery = `SELECT COUNT(*) as count FROM proxies`;
}
const result = await pool.query(countQuery);
const totalProxies = parseInt(result.rows[0].count);
if (totalProxies === 0) {
throw new Error(`No proxies to test with mode '${mode}'`);
}
const jobResult = await pool.query(`
INSERT INTO proxy_test_jobs (status, total_proxies)
VALUES ('pending', $1)
@@ -53,12 +79,12 @@ export async function createProxyTestJob(): Promise<number> {
const jobId = jobResult.rows[0].id;
// Start job in background
runProxyTestJob(jobId).catch(err => {
// Start job in background with mode and concurrency
runProxyTestJob(jobId, mode, concurrency).catch(err => {
console.error(`❌ Proxy test job ${jobId} failed:`, err);
});
return jobId;
return { jobId, totalProxies };
}
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {
@@ -111,7 +137,7 @@ export async function cancelProxyTestJob(jobId: number): Promise<boolean> {
return result.rows.length > 0;
}
async function runProxyTestJob(jobId: number): Promise<void> {
async function runProxyTestJob(jobId: number, mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<void> {
// Register job as active
activeJobs.set(jobId, { cancelled: false });
@@ -125,20 +151,30 @@ async function runProxyTestJob(jobId: number): Promise<void> {
WHERE id = $1
`, [jobId]);
console.log(`🔍 Starting proxy test job ${jobId}...`);
console.log(`🔍 Starting proxy test job ${jobId} (mode: ${mode}, concurrency: ${concurrency})...`);
// Get all proxies
const result = await pool.query(`
SELECT id, host, port, protocol, username, password
FROM proxies
ORDER BY id
`);
// Get proxies based on mode
let query: string;
switch (mode) {
case 'failed':
query = `SELECT id, host, port, protocol, username, password FROM proxies WHERE test_result = 'failed' OR active = false ORDER BY id`;
break;
case 'inactive':
query = `SELECT id, host, port, protocol, username, password FROM proxies WHERE active = false ORDER BY id`;
break;
default:
query = `SELECT id, host, port, protocol, username, password FROM proxies ORDER BY id`;
}
const result = await pool.query(query);
const proxies = result.rows;
let tested = 0;
let passed = 0;
let failed = 0;
for (const proxy of result.rows) {
// Process proxies in batches for parallel testing
for (let i = 0; i < proxies.length; i += concurrency) {
// Check if job was cancelled
const jobControl = activeJobs.get(jobId);
if (jobControl?.cancelled) {
@@ -146,23 +182,34 @@ async function runProxyTestJob(jobId: number): Promise<void> {
break;
}
// Test the proxy
const testResult = await testProxy(
proxy.host,
proxy.port,
proxy.protocol,
proxy.username,
proxy.password
const batch = proxies.slice(i, i + concurrency);
// Test batch in parallel
const batchResults = await Promise.all(
batch.map(async (proxy) => {
const testResult = await testProxy(
proxy.host,
proxy.port,
proxy.protocol,
proxy.username,
proxy.password
);
// Save result
await saveProxyTestResult(proxy.id, testResult);
return testResult.success;
})
);
// Save result
await saveProxyTestResult(proxy.id, testResult);
tested++;
if (testResult.success) {
passed++;
} else {
failed++;
// Count results
for (const success of batchResults) {
tested++;
if (success) {
passed++;
} else {
failed++;
}
}
// Update job progress
@@ -175,10 +222,8 @@ async function runProxyTestJob(jobId: number): Promise<void> {
WHERE id = $4
`, [tested, passed, failed, jobId]);
// Log progress every 10 proxies
if (tested % 10 === 0) {
console.log(`📊 Job ${jobId}: ${tested}/${result.rows.length} proxies tested (${passed} passed, ${failed} failed)`);
}
// Log progress
console.log(`📊 Job ${jobId}: ${tested}/${proxies.length} proxies tested (${passed} passed, ${failed} failed)`);
}
// Mark job as completed

View File

@@ -3,7 +3,7 @@ import StealthPlugin from 'puppeteer-extra-plugin-stealth';
import { Browser, Page } from 'puppeteer';
import { SocksProxyAgent } from 'socks-proxy-agent';
import { pool } from '../db/pool';
import { uploadImageFromUrl, getImageUrl } from '../utils/minio';
import { downloadProductImageLegacy } from '../utils/image-storage';
import { logger } from './logger';
import { registerScraper, updateScraperStats, completeScraper } from '../routes/scraper-monitor';
import { incrementProxyFailure, getActiveProxy, isBotDetectionError, putProxyInTimeout } from './proxy';
@@ -767,7 +767,8 @@ export async function saveProducts(storeId: number, categoryId: number, products
if (product.imageUrl && !localImagePath) {
try {
localImagePath = await uploadImageFromUrl(product.imageUrl, productId);
const result = await downloadProductImageLegacy(product.imageUrl, 0, productId);
localImagePath = result.urls?.original || null;
await client.query(`
UPDATE products
SET local_image_path = $1

View File

@@ -1,13 +1,21 @@
/**
* Entry Point Discovery Handler
*
* Detects menu type and resolves platform IDs for a discovered store.
* Resolves platform IDs for a discovered store using Dutchie GraphQL.
* This is the step between store_discovery and product_discovery.
*
* TODO: Integrate with platform ID resolution when available
* Flow:
* 1. Load dispensary info from database
* 2. Extract slug from menu_url
* 3. Start stealth session (fingerprint + optional proxy)
* 4. Query Dutchie GraphQL to resolve slug → platform_dispensary_id
* 5. Update dispensary record with resolved ID
* 6. Queue product_discovery task if successful
*/
import { TaskContext, TaskResult } from '../task-worker';
import { startSession, endSession } from '../../platforms/dutchie';
import { resolveDispensaryIdWithDetails } from '../../platforms/dutchie/queries';
export async function handleEntryPointDiscovery(ctx: TaskContext): Promise<TaskResult> {
const { pool, task } = ctx;
@@ -18,9 +26,11 @@ export async function handleEntryPointDiscovery(ctx: TaskContext): Promise<TaskR
}
try {
// Get dispensary info
// ============================================================
// STEP 1: Load dispensary info
// ============================================================
const dispResult = await pool.query(`
SELECT id, name, menu_url, platform_dispensary_id, menu_type
SELECT id, name, menu_url, platform_dispensary_id, menu_type, state
FROM dispensaries
WHERE id = $1
`, [dispensaryId]);
@@ -33,7 +43,7 @@ export async function handleEntryPointDiscovery(ctx: TaskContext): Promise<TaskR
// If already has platform_dispensary_id, we're done
if (dispensary.platform_dispensary_id) {
console.log(`[EntryPointDiscovery] Dispensary ${dispensaryId} already has platform ID`);
console.log(`[EntryPointDiscovery] Dispensary ${dispensaryId} already has platform ID: ${dispensary.platform_dispensary_id}`);
return {
success: true,
alreadyResolved: true,
@@ -46,9 +56,12 @@ export async function handleEntryPointDiscovery(ctx: TaskContext): Promise<TaskR
return { success: false, error: `Dispensary ${dispensaryId} has no menu_url` };
}
console.log(`[EntryPointDiscovery] Would resolve platform ID for ${dispensary.name} from ${menuUrl}`);
console.log(`[EntryPointDiscovery] Resolving platform ID for ${dispensary.name}`);
console.log(`[EntryPointDiscovery] Menu URL: ${menuUrl}`);
// Extract slug from menu URL
// ============================================================
// STEP 2: Extract slug from menu URL
// ============================================================
let slug: string | null = null;
const embeddedMatch = menuUrl.match(/\/embedded-menu\/([^/?]+)/);
@@ -61,21 +74,109 @@ export async function handleEntryPointDiscovery(ctx: TaskContext): Promise<TaskR
}
if (!slug) {
// Mark as non-dutchie menu type
await pool.query(`
UPDATE dispensaries
SET menu_type = 'unknown', updated_at = NOW()
WHERE id = $1
`, [dispensaryId]);
return {
success: false,
error: `Could not extract slug from menu_url: ${menuUrl}`,
};
}
// TODO: Integrate with actual platform ID resolution
// For now, mark the task as needing manual resolution
console.log(`[EntryPointDiscovery] Found slug: ${slug} - manual resolution needed`);
console.log(`[EntryPointDiscovery] Extracted slug: ${slug}`);
await ctx.heartbeat();
// ============================================================
// STEP 3: Start stealth session
// ============================================================
const session = startSession(dispensary.state || 'AZ', 'America/Phoenix');
console.log(`[EntryPointDiscovery] Session started: ${session.sessionId}`);
try {
// ============================================================
// STEP 4: Resolve platform ID via GraphQL
// ============================================================
console.log(`[EntryPointDiscovery] Querying Dutchie GraphQL for slug: ${slug}`);
const result = await resolveDispensaryIdWithDetails(slug);
if (!result.dispensaryId) {
// Resolution failed - could be 403, 404, or invalid response
const reason = result.httpStatus
? `HTTP ${result.httpStatus}`
: result.error || 'Unknown error';
console.log(`[EntryPointDiscovery] Failed to resolve ${slug}: ${reason}`);
// Mark as failed resolution but keep menu_type as dutchie
await pool.query(`
UPDATE dispensaries
SET
menu_type = CASE
WHEN $2 = 404 THEN 'removed'
WHEN $2 = 403 THEN 'blocked'
ELSE 'dutchie'
END,
updated_at = NOW()
WHERE id = $1
`, [dispensaryId, result.httpStatus || 0]);
return {
success: false,
error: `Could not resolve platform ID: ${reason}`,
slug,
httpStatus: result.httpStatus,
};
}
const platformId = result.dispensaryId;
console.log(`[EntryPointDiscovery] Resolved ${slug} -> ${platformId}`);
await ctx.heartbeat();
// ============================================================
// STEP 5: Update dispensary with resolved ID
// ============================================================
await pool.query(`
UPDATE dispensaries
SET
platform_dispensary_id = $2,
menu_type = 'dutchie',
crawl_enabled = true,
updated_at = NOW()
WHERE id = $1
`, [dispensaryId, platformId]);
console.log(`[EntryPointDiscovery] Updated dispensary ${dispensaryId} with platform ID`);
// ============================================================
// STEP 6: Queue product_discovery task
// ============================================================
await pool.query(`
INSERT INTO worker_tasks (role, dispensary_id, priority, scheduled_for)
VALUES ('product_discovery', $1, 5, NOW())
ON CONFLICT DO NOTHING
`, [dispensaryId]);
console.log(`[EntryPointDiscovery] Queued product_discovery task for dispensary ${dispensaryId}`);
return {
success: true,
platformId,
slug,
queuedProductDiscovery: true,
};
} finally {
// Always end session
endSession();
}
return {
success: true,
message: 'Slug extracted, awaiting platform ID resolution',
slug,
};
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error(`[EntryPointDiscovery] Error for dispensary ${dispensaryId}:`, errorMessage);

View File

@@ -4,7 +4,7 @@
* Exports all task handlers for the task worker.
*/
export { handleProductResync } from './product-resync';
export { handleProductRefresh } from './product-refresh';
export { handleProductDiscovery } from './product-discovery';
export { handleStoreDiscovery } from './store-discovery';
export { handleEntryPointDiscovery } from './entry-point-discovery';

View File

@@ -6,11 +6,11 @@
*/
import { TaskContext, TaskResult } from '../task-worker';
import { handleProductResync } from './product-resync';
import { handleProductRefresh } from './product-refresh';
export async function handleProductDiscovery(ctx: TaskContext): Promise<TaskResult> {
// Product discovery is essentially the same as resync for the first time
// Product discovery is essentially the same as refresh for the first time
// The main difference is in when this task is triggered (new store vs scheduled)
console.log(`[ProductDiscovery] Starting initial product fetch for dispensary ${ctx.task.dispensary_id}`);
return handleProductResync(ctx);
return handleProductRefresh(ctx);
}

View File

@@ -1,5 +1,5 @@
/**
* Product Resync Handler
* Product Refresh Handler
*
* Re-crawls a store to capture price/stock changes using the GraphQL pipeline.
*
@@ -31,12 +31,12 @@ import {
const normalizer = new DutchieNormalizer();
export async function handleProductResync(ctx: TaskContext): Promise<TaskResult> {
export async function handleProductRefresh(ctx: TaskContext): Promise<TaskResult> {
const { pool, task } = ctx;
const dispensaryId = task.dispensary_id;
if (!dispensaryId) {
return { success: false, error: 'No dispensary_id specified for product_resync task' };
return { success: false, error: 'No dispensary_id specified for product_refresh task' };
}
try {

View File

@@ -17,7 +17,7 @@ export {
export { TaskWorker, TaskContext, TaskResult } from './task-worker';
export {
handleProductResync,
handleProductRefresh,
handleProductDiscovery,
handleStoreDiscovery,
handleEntryPointDiscovery,

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env npx tsx
/**
* Start Pod - Simulates a Kubernetes pod locally
*
* Starts 5 workers with a pod name from the predefined list.
*
* Usage:
* npx tsx src/tasks/start-pod.ts <pod-index>
* npx tsx src/tasks/start-pod.ts 0 # Starts pod "Aethelgard" with 5 workers
* npx tsx src/tasks/start-pod.ts 1 # Starts pod "Xylos" with 5 workers
*/
import { spawn } from 'child_process';
import path from 'path';
const POD_NAMES = [
'Aethelgard',
'Xylos',
'Kryll',
'Coriolis',
'Dimidium',
'Veridia',
'Zetani',
'Talos IV',
'Onyx',
'Celestia',
'Gormand',
'Betha',
'Ragnar',
'Syphon',
'Axiom',
'Nadir',
'Terra Nova',
'Acheron',
'Nexus',
'Vespera',
'Helios Prime',
'Oasis',
'Mordina',
'Cygnus',
'Umbra',
];
const WORKERS_PER_POD = 5;
async function main() {
const podIndex = parseInt(process.argv[2] ?? '0', 10);
if (podIndex < 0 || podIndex >= POD_NAMES.length) {
console.error(`Invalid pod index: ${podIndex}. Must be 0-${POD_NAMES.length - 1}`);
process.exit(1);
}
const podName = POD_NAMES[podIndex];
console.log(`[Pod] Starting pod "${podName}" with ${WORKERS_PER_POD} workers...`);
const workerScript = path.join(__dirname, 'task-worker.ts');
const workers: ReturnType<typeof spawn>[] = [];
for (let i = 1; i <= WORKERS_PER_POD; i++) {
const workerId = `${podName}-worker-${i}`;
const worker = spawn('npx', ['tsx', workerScript], {
env: {
...process.env,
WORKER_ID: workerId,
POD_NAME: podName,
},
stdio: 'inherit',
});
workers.push(worker);
console.log(`[Pod] Started worker ${i}/${WORKERS_PER_POD}: ${workerId}`);
}
// Handle shutdown
const shutdown = () => {
console.log(`\n[Pod] Shutting down pod "${podName}"...`);
workers.forEach(w => w.kill('SIGTERM'));
setTimeout(() => process.exit(0), 2000);
};
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
// Keep the process alive
await new Promise(() => {});
}
main().catch(err => {
console.error('[Pod] Fatal error:', err);
process.exit(1);
});

View File

@@ -10,11 +10,22 @@
import { pool } from '../db/pool';
// Helper to check if a table exists
async function tableExists(tableName: string): Promise<boolean> {
const result = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = $1
) as exists
`, [tableName]);
return result.rows[0].exists;
}
export type TaskRole =
| 'store_discovery'
| 'entry_point_discovery'
| 'product_discovery'
| 'product_resync'
| 'product_refresh'
| 'analytics_refresh';
export type TaskStatus =
@@ -29,6 +40,8 @@ export interface WorkerTask {
id: number;
role: TaskRole;
dispensary_id: number | null;
dispensary_name?: string; // JOINed from dispensaries
dispensary_slug?: string; // JOINed from dispensaries
platform: string | null;
status: TaskStatus;
priority: number;
@@ -128,13 +141,42 @@ class TaskService {
/**
* Claim a task atomically for a worker
* Uses the SQL function for proper locking
* If role is null, claims ANY available task (role-agnostic worker)
*/
async claimTask(role: TaskRole, workerId: string): Promise<WorkerTask | null> {
const result = await pool.query(
`SELECT * FROM claim_task($1, $2)`,
[role, workerId]
);
async claimTask(role: TaskRole | null, workerId: string): Promise<WorkerTask | null> {
if (role) {
// Role-specific claiming - use the SQL function
const result = await pool.query(
`SELECT * FROM claim_task($1, $2)`,
[role, workerId]
);
return (result.rows[0] as WorkerTask) || null;
}
// Role-agnostic claiming - claim ANY pending task
const result = await pool.query(`
UPDATE worker_tasks
SET
status = 'claimed',
worker_id = $1,
claimed_at = NOW()
WHERE id = (
SELECT id FROM worker_tasks
WHERE status = 'pending'
AND (scheduled_for IS NULL OR scheduled_for <= NOW())
-- Exclude stores that already have an active task
AND (dispensary_id IS NULL OR dispensary_id NOT IN (
SELECT dispensary_id FROM worker_tasks
WHERE status IN ('claimed', 'running')
AND dispensary_id IS NOT NULL
))
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *
`, [workerId]);
return (result.rows[0] as WorkerTask) || null;
}
@@ -175,15 +217,53 @@ class TaskService {
}
/**
* Mark a task as failed
* Mark a task as failed, with auto-retry if under max_retries
* Returns true if task was re-queued for retry, false if permanently failed
*/
async failTask(taskId: number, errorMessage: string): Promise<void> {
async failTask(taskId: number, errorMessage: string): Promise<boolean> {
// Get current retry state
const result = await pool.query(
`SELECT retry_count, max_retries FROM worker_tasks WHERE id = $1`,
[taskId]
);
if (result.rows.length === 0) {
return false;
}
const { retry_count, max_retries } = result.rows[0];
const newRetryCount = (retry_count || 0) + 1;
if (newRetryCount < (max_retries || 3)) {
// Re-queue for retry - reset to pending with incremented retry_count
await pool.query(
`UPDATE worker_tasks
SET status = 'pending',
worker_id = NULL,
claimed_at = NULL,
started_at = NULL,
retry_count = $2,
error_message = $3,
updated_at = NOW()
WHERE id = $1`,
[taskId, newRetryCount, `Retry ${newRetryCount}: ${errorMessage}`]
);
console.log(`[TaskService] Task ${taskId} queued for retry ${newRetryCount}/${max_retries || 3}`);
return true;
}
// Max retries exceeded - mark as permanently failed
await pool.query(
`UPDATE worker_tasks
SET status = 'failed', completed_at = NOW(), error_message = $2
SET status = 'failed',
completed_at = NOW(),
retry_count = $2,
error_message = $3
WHERE id = $1`,
[taskId, errorMessage]
[taskId, newRetryCount, `Failed after ${newRetryCount} attempts: ${errorMessage}`]
);
console.log(`[TaskService] Task ${taskId} permanently failed after ${newRetryCount} attempts`);
return false;
}
/**
@@ -201,32 +281,37 @@ class TaskService {
* List tasks with filters
*/
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
// Return empty list if table doesn't exist
if (!await tableExists('worker_tasks')) {
return [];
}
const conditions: string[] = [];
const params: (string | number | string[])[] = [];
let paramIndex = 1;
if (filter.role) {
conditions.push(`role = $${paramIndex++}`);
conditions.push(`t.role = $${paramIndex++}`);
params.push(filter.role);
}
if (filter.status) {
if (Array.isArray(filter.status)) {
conditions.push(`status = ANY($${paramIndex++})`);
conditions.push(`t.status = ANY($${paramIndex++})`);
params.push(filter.status);
} else {
conditions.push(`status = $${paramIndex++}`);
conditions.push(`t.status = $${paramIndex++}`);
params.push(filter.status);
}
}
if (filter.dispensary_id) {
conditions.push(`dispensary_id = $${paramIndex++}`);
conditions.push(`t.dispensary_id = $${paramIndex++}`);
params.push(filter.dispensary_id);
}
if (filter.worker_id) {
conditions.push(`worker_id = $${paramIndex++}`);
conditions.push(`t.worker_id = $${paramIndex++}`);
params.push(filter.worker_id);
}
@@ -235,9 +320,14 @@ class TaskService {
const offset = filter.offset ?? 0;
const result = await pool.query(
`SELECT * FROM worker_tasks
`SELECT
t.*,
d.name as dispensary_name,
d.slug as dispensary_slug
FROM worker_tasks t
LEFT JOIN dispensaries d ON d.id = t.dispensary_id
${whereClause}
ORDER BY created_at DESC
ORDER BY t.created_at DESC
LIMIT ${limit} OFFSET ${offset}`,
params
);
@@ -249,21 +339,41 @@ class TaskService {
* Get capacity metrics for all roles
*/
async getCapacityMetrics(): Promise<CapacityMetrics[]> {
const result = await pool.query(
`SELECT * FROM v_worker_capacity`
);
return result.rows as CapacityMetrics[];
// Return empty metrics if worker_tasks table doesn't exist
if (!await tableExists('worker_tasks')) {
return [];
}
try {
const result = await pool.query(
`SELECT * FROM v_worker_capacity`
);
return result.rows as CapacityMetrics[];
} catch {
// View may not exist
return [];
}
}
/**
* Get capacity metrics for a specific role
*/
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
const result = await pool.query(
`SELECT * FROM v_worker_capacity WHERE role = $1`,
[role]
);
return (result.rows[0] as CapacityMetrics) || null;
// Return null if worker_tasks table doesn't exist
if (!await tableExists('worker_tasks')) {
return null;
}
try {
const result = await pool.query(
`SELECT * FROM v_worker_capacity WHERE role = $1`,
[role]
);
return (result.rows[0] as CapacityMetrics) || null;
} catch {
// View may not exist
return null;
}
}
/**
@@ -389,12 +499,6 @@ class TaskService {
* Get task counts by status for dashboard
*/
async getTaskCounts(): Promise<Record<TaskStatus, number>> {
const result = await pool.query(
`SELECT status, COUNT(*) as count
FROM worker_tasks
GROUP BY status`
);
const counts: Record<TaskStatus, number> = {
pending: 0,
claimed: 0,
@@ -404,6 +508,17 @@ class TaskService {
stale: 0,
};
// Return empty counts if table doesn't exist
if (!await tableExists('worker_tasks')) {
return counts;
}
const result = await pool.query(
`SELECT status, COUNT(*) as count
FROM worker_tasks
GROUP BY status`
);
for (const row of result.rows) {
const typedRow = row as { status: TaskStatus; count: string };
counts[typedRow.status] = parseInt(typedRow.count, 10);

View File

@@ -1,26 +1,58 @@
/**
* Task Worker
*
* A unified worker that processes tasks from the worker_tasks queue.
* Replaces the fragmented job systems (job_schedules, dispensary_crawl_jobs, etc.)
* A unified worker that pulls tasks from the worker_tasks queue.
* Workers register on startup, get a friendly name, and pull tasks.
*
* Architecture:
* - Tasks are generated on schedule (by scheduler or API)
* - Workers PULL tasks from the pool (not assigned to them)
* - Tasks are claimed in order of priority (DESC) then creation time (ASC)
* - Workers report heartbeats to worker_registry
* - Workers are ROLE-AGNOSTIC by default (can handle any task type)
*
* Stealth & Anti-Detection:
* PROXIES ARE REQUIRED - workers will fail to start if no proxies available.
*
* On startup, workers initialize the CrawlRotator which provides:
* - Proxy rotation: Loads proxies from `proxies` table, ALL requests use proxy
* - User-Agent rotation: Cycles through realistic browser fingerprints
* - Fingerprint rotation: Changes browser profile on blocks
* - Locale/timezone: Matches Accept-Language to target state
*
* The CrawlRotator is wired to the Dutchie client via setCrawlRotator().
* Task handlers call startSession() which picks a random fingerprint.
* On 403 errors, the client automatically:
* 1. Records failure on current proxy
* 2. Rotates to next proxy
* 3. Rotates fingerprint
* 4. Retries the request
*
* Usage:
* WORKER_ROLE=product_resync npx tsx src/tasks/task-worker.ts
* npx tsx src/tasks/task-worker.ts # Role-agnostic (any task)
* WORKER_ROLE=product_refresh npx tsx src/tasks/task-worker.ts # Role-specific
*
* Environment:
* WORKER_ROLE - Which task role to process (required)
* WORKER_ID - Optional custom worker ID
* WORKER_ROLE - Which task role to process (optional, null = any task)
* WORKER_ID - Optional custom worker ID (auto-generated if not provided)
* POD_NAME - Kubernetes pod name (optional)
* POLL_INTERVAL_MS - How often to check for tasks (default: 5000)
* HEARTBEAT_INTERVAL_MS - How often to update heartbeat (default: 30000)
* API_BASE_URL - Backend API URL for registration (default: http://localhost:3010)
*/
import { Pool } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import { taskService, TaskRole, WorkerTask } from './task-service';
import { getPool } from '../db/pool';
import os from 'os';
// Stealth/rotation support
import { CrawlRotator } from '../services/crawl-rotator';
import { setCrawlRotator } from '../platforms/dutchie';
// Task handlers by role
import { handleProductResync } from './handlers/product-resync';
import { handleProductRefresh } from './handlers/product-refresh';
import { handleProductDiscovery } from './handlers/product-discovery';
import { handleStoreDiscovery } from './handlers/store-discovery';
import { handleEntryPointDiscovery } from './handlers/entry-point-discovery';
@@ -28,6 +60,7 @@ import { handleAnalyticsRefresh } from './handlers/analytics-refresh';
const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS || '5000');
const HEARTBEAT_INTERVAL_MS = parseInt(process.env.HEARTBEAT_INTERVAL_MS || '30000');
const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3010';
export interface TaskContext {
pool: Pool;
@@ -48,7 +81,7 @@ export interface TaskResult {
type TaskHandler = (ctx: TaskContext) => Promise<TaskResult>;
const TASK_HANDLERS: Record<TaskRole, TaskHandler> = {
product_resync: handleProductResync,
product_refresh: handleProductRefresh,
product_discovery: handleProductDiscovery,
store_discovery: handleStoreDiscovery,
entry_point_discovery: handleEntryPointDiscovery,
@@ -58,15 +91,162 @@ const TASK_HANDLERS: Record<TaskRole, TaskHandler> = {
export class TaskWorker {
private pool: Pool;
private workerId: string;
private role: TaskRole;
private role: TaskRole | null; // null = role-agnostic (any task)
private friendlyName: string = '';
private isRunning: boolean = false;
private heartbeatInterval: NodeJS.Timeout | null = null;
private registryHeartbeatInterval: NodeJS.Timeout | null = null;
private currentTask: WorkerTask | null = null;
private crawlRotator: CrawlRotator;
constructor(role: TaskRole, workerId?: string) {
constructor(role: TaskRole | null = null, workerId?: string) {
this.pool = getPool();
this.role = role;
this.workerId = workerId || `worker-${role}-${uuidv4().slice(0, 8)}`;
this.workerId = workerId || `worker-${uuidv4().slice(0, 8)}`;
this.crawlRotator = new CrawlRotator(this.pool);
}
/**
* Initialize stealth systems (proxy rotation, fingerprints)
* Called once on worker startup before processing any tasks.
*
* IMPORTANT: Proxies are REQUIRED. Workers will fail to start if no proxies available.
*/
private async initializeStealth(): Promise<void> {
// Load proxies from database
await this.crawlRotator.initialize();
const stats = this.crawlRotator.proxy.getStats();
if (stats.activeProxies === 0) {
throw new Error('No active proxies available. Workers MUST use proxies for all requests. Add proxies to the database before starting workers.');
}
console.log(`[TaskWorker] Loaded ${stats.activeProxies} proxies (${stats.avgSuccessRate.toFixed(1)}% avg success rate)`);
// Wire rotator to Dutchie client - proxies will be used for ALL requests
setCrawlRotator(this.crawlRotator);
console.log(`[TaskWorker] Stealth initialized: ${this.crawlRotator.userAgent.getCount()} fingerprints, proxy REQUIRED for all requests`);
}
/**
* Register worker with the registry (get friendly name)
*/
private async register(): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/api/worker-registry/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: this.role,
worker_id: this.workerId,
pod_name: process.env.POD_NAME || process.env.HOSTNAME,
hostname: os.hostname(),
metadata: {
pid: process.pid,
node_version: process.version,
started_at: new Date().toISOString()
}
})
});
const data = await response.json();
if (data.success) {
this.friendlyName = data.friendly_name;
console.log(`[TaskWorker] ${data.message}`);
} else {
console.warn(`[TaskWorker] Registration warning: ${data.error}`);
this.friendlyName = this.workerId.slice(0, 12);
}
} catch (error: any) {
// Registration is optional - worker can still function without it
console.warn(`[TaskWorker] Could not register with API (will continue): ${error.message}`);
this.friendlyName = this.workerId.slice(0, 12);
}
}
/**
* Deregister worker from the registry
*/
private async deregister(): Promise<void> {
try {
await fetch(`${API_BASE_URL}/api/worker-registry/deregister`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ worker_id: this.workerId })
});
console.log(`[TaskWorker] ${this.friendlyName} signed off`);
} catch {
// Ignore deregistration errors
}
}
/**
* Send heartbeat to registry with resource usage and proxy location
*/
private async sendRegistryHeartbeat(): Promise<void> {
try {
const memUsage = process.memoryUsage();
const cpuUsage = process.cpuUsage();
const proxyLocation = this.crawlRotator.getProxyLocation();
await fetch(`${API_BASE_URL}/api/worker-registry/heartbeat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worker_id: this.workerId,
current_task_id: this.currentTask?.id || null,
status: this.currentTask ? 'active' : 'idle',
resources: {
memory_mb: Math.round(memUsage.heapUsed / 1024 / 1024),
memory_total_mb: Math.round(memUsage.heapTotal / 1024 / 1024),
memory_rss_mb: Math.round(memUsage.rss / 1024 / 1024),
cpu_user_ms: Math.round(cpuUsage.user / 1000),
cpu_system_ms: Math.round(cpuUsage.system / 1000),
proxy_location: proxyLocation,
}
})
});
} catch {
// Ignore heartbeat errors
}
}
/**
* Report task completion to registry
*/
private async reportTaskCompletion(success: boolean): Promise<void> {
try {
await fetch(`${API_BASE_URL}/api/worker-registry/task-completed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worker_id: this.workerId,
success
})
});
} catch {
// Ignore errors
}
}
/**
* Start registry heartbeat interval
*/
private startRegistryHeartbeat(): void {
this.registryHeartbeatInterval = setInterval(async () => {
await this.sendRegistryHeartbeat();
}, HEARTBEAT_INTERVAL_MS);
}
/**
* Stop registry heartbeat interval
*/
private stopRegistryHeartbeat(): void {
if (this.registryHeartbeatInterval) {
clearInterval(this.registryHeartbeatInterval);
this.registryHeartbeatInterval = null;
}
}
/**
@@ -74,7 +254,18 @@ export class TaskWorker {
*/
async start(): Promise<void> {
this.isRunning = true;
console.log(`[TaskWorker] Starting worker ${this.workerId} for role: ${this.role}`);
// Initialize stealth systems (proxy rotation, fingerprints)
await this.initializeStealth();
// Register with the API to get a friendly name
await this.register();
// Start registry heartbeat
this.startRegistryHeartbeat();
const roleMsg = this.role ? `for role: ${this.role}` : '(role-agnostic - any task)';
console.log(`[TaskWorker] ${this.friendlyName} starting ${roleMsg}`);
while (this.isRunning) {
try {
@@ -91,10 +282,12 @@ export class TaskWorker {
/**
* Stop the worker
*/
stop(): void {
async stop(): Promise<void> {
this.isRunning = false;
this.stopHeartbeat();
console.log(`[TaskWorker] Stopping worker ${this.workerId}...`);
this.stopRegistryHeartbeat();
await this.deregister();
console.log(`[TaskWorker] ${this.friendlyName} stopped`);
}
/**
@@ -142,7 +335,8 @@ export class TaskWorker {
if (result.success) {
// Mark as completed
await taskService.completeTask(task.id, result);
console.log(`[TaskWorker] Task ${task.id} completed successfully`);
await this.reportTaskCompletion(true);
console.log(`[TaskWorker] ${this.friendlyName} completed task ${task.id}`);
// Chain next task if applicable
const chainedTask = await taskService.chainNextTask({
@@ -156,12 +350,14 @@ export class TaskWorker {
} else {
// Mark as failed
await taskService.failTask(task.id, result.error || 'Unknown error');
console.log(`[TaskWorker] Task ${task.id} failed: ${result.error}`);
await this.reportTaskCompletion(false);
console.log(`[TaskWorker] ${this.friendlyName} failed task ${task.id}: ${result.error}`);
}
} catch (error: any) {
// Mark as failed
await taskService.failTask(task.id, error.message);
console.error(`[TaskWorker] Task ${task.id} threw error:`, error.message);
await this.reportTaskCompletion(false);
console.error(`[TaskWorker] ${this.friendlyName} task ${task.id} error:`, error.message);
} finally {
this.stopHeartbeat();
this.currentTask = null;
@@ -201,7 +397,7 @@ export class TaskWorker {
/**
* Get worker info
*/
getInfo(): { workerId: string; role: TaskRole; isRunning: boolean; currentTaskId: number | null } {
getInfo(): { workerId: string; role: TaskRole | null; isRunning: boolean; currentTaskId: number | null } {
return {
workerId: this.workerId,
role: this.role,
@@ -216,30 +412,27 @@ export class TaskWorker {
// ============================================================
async function main(): Promise<void> {
const role = process.env.WORKER_ROLE as TaskRole;
if (!role) {
console.error('Error: WORKER_ROLE environment variable is required');
console.error('Valid roles: store_discovery, entry_point_discovery, product_discovery, product_resync, analytics_refresh');
process.exit(1);
}
const role = process.env.WORKER_ROLE as TaskRole | undefined;
const validRoles: TaskRole[] = [
'store_discovery',
'entry_point_discovery',
'product_discovery',
'product_resync',
'product_refresh',
'analytics_refresh',
];
if (!validRoles.includes(role)) {
// If role specified, validate it
if (role && !validRoles.includes(role)) {
console.error(`Error: Invalid WORKER_ROLE: ${role}`);
console.error(`Valid roles: ${validRoles.join(', ')}`);
console.error('Or omit WORKER_ROLE for role-agnostic worker (any task)');
process.exit(1);
}
const workerId = process.env.WORKER_ID;
const worker = new TaskWorker(role, workerId);
// Pass null for role-agnostic, or the specific role
const worker = new TaskWorker(role || null, workerId);
// Handle graceful shutdown
process.on('SIGTERM', () => {

View File

@@ -6,8 +6,8 @@ WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Install dependencies (npm install is more forgiving than npm ci)
RUN npm install
# Copy source files
COPY . .

View File

@@ -69,6 +69,13 @@ class ApiClient {
return { data };
}
async delete<T = any>(endpoint: string): Promise<{ data: T }> {
const data = await this.request<T>(endpoint, {
method: 'DELETE',
});
return { data };
}
// Auth
async login(email: string, password: string) {
return this.request<{ token: string; user: any }>('/api/auth/login', {
@@ -113,7 +120,7 @@ class ApiClient {
});
}
async getDispensaries(params?: { limit?: number; offset?: number; search?: string; city?: string; state?: string; crawl_enabled?: string }) {
async getDispensaries(params?: { limit?: number; offset?: number; search?: string; city?: string; state?: string; crawl_enabled?: string; status?: string }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
@@ -121,10 +128,15 @@ class ApiClient {
if (params?.city) searchParams.append('city', params.city);
if (params?.state) searchParams.append('state', params.state);
if (params?.crawl_enabled) searchParams.append('crawl_enabled', params.crawl_enabled);
if (params?.status) searchParams.append('status', params.status);
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{ dispensaries: any[]; total: number; limit: number; offset: number; hasMore: boolean }>(`/api/dispensaries${queryString}`);
}
async getDroppedStores() {
return this.request<{ dropped_count: number; dropped_stores: any[] }>('/api/dispensaries/stats/dropped');
}
async getDispensary(slug: string) {
return this.request<any>(`/api/dispensaries/${slug}`);
}
@@ -308,7 +320,7 @@ class ApiClient {
}
async testAllProxies() {
return this.request<{ jobId: number; message: string }>('/api/proxies/test-all', {
return this.request<{ jobId: number; total: number; message: string }>('/api/proxies/test-all', {
method: 'POST',
});
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { Toast } from '../components/Toast';
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown } from 'lucide-react';
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown, Pencil } from 'lucide-react';
interface ApiPermission {
id: number;
@@ -161,6 +161,12 @@ export function ApiPermissions() {
allowed_ips: '',
allowed_domains: '',
});
const [editingPermission, setEditingPermission] = useState<ApiPermission | null>(null);
const [editForm, setEditForm] = useState({
user_name: '',
allowed_ips: '',
allowed_domains: '',
});
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
useEffect(() => {
@@ -240,6 +246,33 @@ export function ApiPermissions() {
}
};
const handleEdit = (perm: ApiPermission) => {
setEditingPermission(perm);
setEditForm({
user_name: perm.user_name,
allowed_ips: perm.allowed_ips || '',
allowed_domains: perm.allowed_domains || '',
});
};
const handleSaveEdit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingPermission) return;
try {
await api.updateApiPermission(editingPermission.id, {
user_name: editForm.user_name,
allowed_ips: editForm.allowed_ips || undefined,
allowed_domains: editForm.allowed_domains || undefined,
});
setNotification({ message: 'API key updated successfully', type: 'success' });
setEditingPermission(null);
loadPermissions();
} catch (error: any) {
setNotification({ message: 'Failed to update permission: ' + error.message, type: 'error' });
}
};
const copyToClipboard = async (text: string, id: number) => {
await navigator.clipboard.writeText(text);
setCopiedId(id);
@@ -494,21 +527,36 @@ export function ApiPermissions() {
</button>
</div>
{/* Restrictions */}
{(perm.allowed_ips || perm.allowed_domains) && (
<div className="flex gap-4 mt-3 text-xs text-gray-500">
{perm.allowed_ips && (
<span>IPs: {perm.allowed_ips.split('\n').length} allowed</span>
{/* Allowed Domains - Always show */}
<div className="mt-3 text-xs">
<span className="text-gray-500 flex items-center gap-1">
<Globe className="w-3 h-3" />
Domains:{' '}
{perm.allowed_domains ? (
<span className="text-gray-700 font-mono">
{perm.allowed_domains.split('\n').filter(d => d.trim()).join(', ')}
</span>
) : (
<span className="text-amber-600">Any domain (no restriction)</span>
)}
{perm.allowed_domains && (
<span>Domains: {perm.allowed_domains.split('\n').length} allowed</span>
)}
</div>
)}
</span>
{perm.allowed_ips && (
<span className="text-gray-500 ml-4">
IPs: {perm.allowed_ips.split('\n').filter(ip => ip.trim()).length} allowed
</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => handleEdit(perm)}
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Edit"
>
<Pencil className="w-5 h-5" />
</button>
<button
onClick={() => handleToggle(perm.id)}
className={`p-2 rounded-lg transition-colors ${
@@ -534,6 +582,86 @@ export function ApiPermissions() {
</div>
)}
</div>
{/* Edit Modal */}
{editingPermission && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<Pencil className="w-5 h-5 text-blue-600" />
Edit API Key
</h2>
<p className="text-sm text-gray-500 mt-1">
{editingPermission.store_name}
</p>
</div>
<form onSubmit={handleSaveEdit} className="p-6 space-y-5">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Label / Website Name
</label>
<input
type="text"
value={editForm.user_name}
onChange={(e) => setEditForm({ ...editForm, user_name: e.target.value })}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Globe className="w-4 h-4 inline mr-1" />
Allowed Domains
</label>
<textarea
value={editForm.allowed_domains}
onChange={(e) => setEditForm({ ...editForm, allowed_domains: e.target.value })}
rows={4}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
placeholder="example.com&#10;*.example.com&#10;subdomain.example.com"
/>
<p className="text-xs text-gray-500 mt-1">
One domain per line. Use * for wildcards (e.g., *.example.com). Leave empty to allow any domain.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Shield className="w-4 h-4 inline mr-1" />
Allowed IP Addresses
</label>
<textarea
value={editForm.allowed_ips}
onChange={(e) => setEditForm({ ...editForm, allowed_ips: e.target.value })}
rows={3}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
placeholder="192.168.1.1&#10;10.0.0.0/8"
/>
<p className="text-xs text-gray-500 mt-1">One per line. CIDR notation supported. Leave empty to allow any IP.</p>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
className="flex-1 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Save Changes
</button>
<button
type="button"
onClick={() => setEditingPermission(null)}
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
</Layout>
);

View File

@@ -46,12 +46,33 @@ export function Dashboard() {
const [pendingChangesCount, setPendingChangesCount] = useState(0);
const [showNotification, setShowNotification] = useState(false);
const [taskCounts, setTaskCounts] = useState<Record<string, number> | null>(null);
const [droppedStoresCount, setDroppedStoresCount] = useState(0);
const [showDroppedAlert, setShowDroppedAlert] = useState(false);
useEffect(() => {
loadData();
checkNotificationStatus();
checkDroppedStores();
}, []);
const checkDroppedStores = async () => {
try {
const data = await api.getDroppedStores();
setDroppedStoresCount(data.dropped_count);
// Check if notification was dismissed for this count
const dismissedCount = localStorage.getItem('dismissedDroppedStoresCount');
const isDismissed = dismissedCount && parseInt(dismissedCount) >= data.dropped_count;
setShowDroppedAlert(data.dropped_count > 0 && !isDismissed);
} catch (error) {
console.error('Failed to check dropped stores:', error);
}
};
const handleDismissDroppedAlert = () => {
localStorage.setItem('dismissedDroppedStoresCount', droppedStoresCount.toString());
setShowDroppedAlert(false);
};
const checkNotificationStatus = async () => {
try {
// Fetch real pending changes count from API
@@ -214,6 +235,40 @@ export function Dashboard() {
</div>
)}
{/* Dropped Stores Alert */}
{showDroppedAlert && (
<div className="mb-6 bg-red-50 border-l-4 border-red-500 rounded-lg p-3 sm:p-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div className="flex items-start sm:items-center gap-3 flex-1">
<Store className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5 sm:mt-0" />
<div className="flex-1">
<h3 className="text-sm font-semibold text-red-900">
{droppedStoresCount} dropped store{droppedStoresCount !== 1 ? 's' : ''} need{droppedStoresCount === 1 ? 's' : ''} review
</h3>
<p className="text-xs sm:text-sm text-red-700 mt-0.5">
These stores were not found in the latest Dutchie discovery and may have stopped using the platform
</p>
</div>
</div>
<div className="flex items-center gap-2 pl-8 sm:pl-0">
<button
onClick={() => navigate('/dispensaries?status=dropped')}
className="btn btn-sm bg-red-600 hover:bg-red-700 text-white border-none"
>
Review
</button>
<button
onClick={handleDismissDroppedAlert}
className="btn btn-sm btn-ghost text-red-900 hover:bg-red-100"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">

View File

@@ -13,6 +13,7 @@ export function Dispensaries() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [filterState, setFilterState] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [editingDispensary, setEditingDispensary] = useState<any | null>(null);
const [editForm, setEditForm] = useState<any>({});
const [total, setTotal] = useState(0);
@@ -51,6 +52,7 @@ export function Dispensaries() {
offset,
search: debouncedSearch || undefined,
state: filterState || undefined,
status: filterStatus || undefined,
crawl_enabled: 'all'
});
setDispensaries(data.dispensaries);
@@ -61,7 +63,7 @@ export function Dispensaries() {
} finally {
setLoading(false);
}
}, [offset, debouncedSearch, filterState]);
}, [offset, debouncedSearch, filterState, filterStatus]);
useEffect(() => {
loadDispensaries();
@@ -110,6 +112,11 @@ export function Dispensaries() {
setOffset(0); // Reset to first page
};
const handleStatusFilter = (status: string) => {
setFilterStatus(status);
setOffset(0); // Reset to first page
};
return (
<Layout>
<div className="space-y-6">
@@ -123,7 +130,7 @@ export function Dispensaries() {
{/* Filters */}
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search
@@ -154,6 +161,23 @@ export function Dispensaries() {
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by Status
</label>
<select
value={filterStatus}
onChange={(e) => handleStatusFilter(e.target.value)}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 ${
filterStatus === 'dropped' ? 'border-red-300 bg-red-50' : 'border-gray-300'
}`}
>
<option value="">All Statuses</option>
<option value="open">Open</option>
<option value="dropped">Dropped (Needs Review)</option>
<option value="closed">Closed</option>
</select>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,13 @@ import { useEffect, useState } from 'react';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { Toast } from '../components/Toast';
import { Shield, CheckCircle, XCircle, RefreshCw, Plus, MapPin, Clock, TrendingUp, Trash2, AlertCircle, Upload, FileText, X } from 'lucide-react';
import { Shield, CheckCircle, XCircle, RefreshCw, Plus, MapPin, Clock, TrendingUp, Trash2, AlertCircle, Upload, FileText, X, Edit2 } from 'lucide-react';
export function Proxies() {
const [proxies, setProxies] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [editingProxy, setEditingProxy] = useState<any>(null);
const [testing, setTesting] = useState<{ [key: number]: boolean }>({});
const [activeJob, setActiveJob] = useState<any>(null);
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
@@ -95,7 +96,8 @@ export function Proxies() {
try {
const response = await api.testAllProxies();
setNotification({ message: 'Proxy testing job started', type: 'success' });
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: proxies.length, passed_proxies: 0, failed_proxies: 0 });
// Use response.total if available, otherwise proxies.length, but immediately poll for accurate count
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: response.total || proxies.length || 0, passed_proxies: 0, failed_proxies: 0 });
} catch (error: any) {
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
}
@@ -342,6 +344,18 @@ export function Proxies() {
/>
)}
{/* Edit Proxy Modal */}
{editingProxy && (
<EditProxyModal
proxy={editingProxy}
onClose={() => setEditingProxy(null)}
onSuccess={() => {
setEditingProxy(null);
loadProxies();
}}
/>
)}
{/* Proxy List */}
<div className="space-y-3">
{proxies.map(proxy => (
@@ -360,6 +374,9 @@ export function Proxies() {
{proxy.is_anonymous && (
<span className="px-2 py-1 text-xs font-medium bg-blue-50 text-blue-700 rounded">Anonymous</span>
)}
{proxy.max_connections > 1 && (
<span className="px-2 py-1 text-xs font-medium bg-orange-50 text-orange-700 rounded">{proxy.max_connections} connections</span>
)}
{(proxy.city || proxy.state || proxy.country) && (
<span className="px-2 py-1 text-xs font-medium bg-purple-50 text-purple-700 rounded flex items-center gap-1">
<MapPin className="w-3 h-3" />
@@ -394,6 +411,13 @@ export function Proxies() {
</div>
<div className="flex gap-2">
<button
onClick={() => setEditingProxy(proxy)}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-gray-50 text-gray-700 rounded-lg hover:bg-gray-100 transition-colors text-sm font-medium"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
{!proxy.active ? (
<button
onClick={() => handleRetest(proxy.id)}
@@ -762,3 +786,157 @@ function AddProxyForm({ onClose, onSuccess }: { onClose: () => void; onSuccess:
</div>
);
}
function EditProxyModal({ proxy, onClose, onSuccess }: { proxy: any; onClose: () => void; onSuccess: () => void }) {
const [host, setHost] = useState(proxy.host || '');
const [port, setPort] = useState(proxy.port?.toString() || '');
const [protocol, setProtocol] = useState(proxy.protocol || 'http');
const [username, setUsername] = useState(proxy.username || '');
const [password, setPassword] = useState(proxy.password || '');
const [maxConnections, setMaxConnections] = useState(proxy.max_connections?.toString() || '1');
const [loading, setSaving] = useState(false);
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
await api.updateProxy(proxy.id, {
host,
port: parseInt(port),
protocol,
username: username || undefined,
password: password || undefined,
max_connections: parseInt(maxConnections) || 1,
});
onSuccess();
} catch (error: any) {
setNotification({ message: 'Failed to update proxy: ' + error.message, type: 'error' });
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full mx-4">
{notification && (
<Toast
message={notification.message}
type={notification.type}
onClose={() => setNotification(null)}
/>
)}
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">Edit Proxy</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Host</label>
<input
type="text"
value={host}
onChange={(e) => setHost(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Port</label>
<input
type="number"
value={port}
onChange={(e) => setPort(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Protocol</label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Optional"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Max Connections</label>
<input
type="number"
value={maxConnections}
onChange={(e) => setMaxConnections(e.target.value)}
min="1"
max="500"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">For rotating proxies - allows concurrent connections</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6 pt-6 border-t border-gray-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors font-medium"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-50"
>
{loading ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Saving...
</>
) : (
'Save Changes'
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -69,7 +69,7 @@ const ROLES = [
'store_discovery',
'entry_point_discovery',
'product_discovery',
'product_resync',
'product_refresh',
'analytics_refresh',
];

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
import { useState, useEffect } from 'react';
import { api } from '../../../lib/api';
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2 } from 'lucide-react';
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
interface SeoPage {
id: number;
@@ -47,11 +47,31 @@ export function PagesTab() {
const [search, setSearch] = useState('');
const [syncing, setSyncing] = useState(false);
const [generatingId, setGeneratingId] = useState<number | null>(null);
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
useEffect(() => {
loadPages();
checkAiProvider();
}, [typeFilter, search]);
async function checkAiProvider() {
try {
const data = await api.getSettings();
const settings = data.settings || [];
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
setHasActiveAiProvider(!!hasProvider);
} catch (error) {
console.error('Failed to check AI provider:', error);
setHasActiveAiProvider(false);
}
}
async function loadPages() {
setLoading(true);
try {
@@ -188,12 +208,18 @@ export function PagesTab() {
<td className="px-3 sm:px-4 py-3">
<button
onClick={() => handleGenerate(page.id)}
disabled={generatingId === page.id}
className="flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium bg-purple-50 text-purple-700 rounded-lg hover:bg-purple-100 disabled:opacity-50"
title="Generate content"
disabled={generatingId === page.id || hasActiveAiProvider === false}
className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
hasActiveAiProvider === false
? 'bg-gray-100 text-gray-400'
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
}`}
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
>
{generatingId === page.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : hasActiveAiProvider === false ? (
<AlertCircle className="w-3.5 h-3.5" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}

353
docs/CRAWL_SYSTEM_V2.md Normal file
View File

@@ -0,0 +1,353 @@
# CannaiQ Crawl System V2
## Overview
The CannaiQ Crawl System is a GraphQL-based data pipeline that discovers and monitors cannabis dispensaries using the Dutchie platform. It operates in two phases:
1. **Phase 1: Store Discovery** - Weekly discovery of Dutchie-powered dispensaries
2. **Phase 2: Product Crawling** - Regular product/price/stock updates (documented separately)
---
## Phase 1: Store Discovery
### Purpose
Automatically discover and maintain a database of dispensaries that use Dutchie menus across all US states.
### Schedule
- **Frequency**: Weekly (typically Sunday night)
- **Duration**: ~2-4 hours for full US coverage
### Flow Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ PHASE 1: STORE DISCOVERY │
└─────────────────────────────────────────────────────────────────────┘
1. IDENTITY SETUP
┌──────────────────┐
│ getRandomProxy() │ ──► Random IP from proxy pool
└──────────────────┘
┌──────────────────┐
│ startSession() │ ──► Random UA + fingerprint + locale matching proxy location
└──────────────────┘
2. CITY DISCOVERY (per state)
┌──────────────────────────────┐
│ GraphQL: getAllCitiesByState │ ──► Returns cities with active dispensaries
└──────────────────────────────┘
┌──────────────────────────────┐
│ Upsert dutchie_discovery_ │
│ cities table │
└──────────────────────────────┘
3. STORE DISCOVERY (per city)
┌───────────────────────────────┐
│ GraphQL: ConsumerDispensaries │ ──► Returns store data for city
└───────────────────────────────┘
┌───────────────────────────────┐
│ Upsert dutchie_discovery_ │
│ locations table │
└───────────────────────────────┘
4. VALIDATION & PROMOTION
┌──────────────────────────┐
│ validateForPromotion() │ ──► Check required fields
└──────────────────────────┘
┌──────────────────────────┐
│ promoteLocation() │ ──► Upsert to dispensaries table
└──────────────────────────┘
┌──────────────────────────┐
│ ensureCrawlerProfile() │ ──► Create profile with status='sandbox'
└──────────────────────────┘
5. DROPPED STORE DETECTION
┌──────────────────────────┐
│ detectDroppedStores() │ ──► Find stores missing from discovery
└──────────────────────────┘
┌──────────────────────────┐
│ Mark status='dropped' │ ──► Dashboard alert for review
└──────────────────────────┘
```
---
## Key Files
| File | Purpose |
|------|---------|
| `backend/src/platforms/dutchie/client.ts` | HTTP client with proxy/fingerprint rotation |
| `backend/src/discovery/discovery-crawler.ts` | Main discovery orchestrator |
| `backend/src/discovery/location-discovery.ts` | City/store GraphQL fetching |
| `backend/src/discovery/promotion.ts` | Validation and promotion logic |
| `backend/src/scripts/run-discovery.ts` | CLI entry point |
---
## Identity Masking
Before any GraphQL queries, the system establishes a masked identity:
### 1. Proxy Selection
```typescript
// backend/src/platforms/dutchie/client.ts
// Get random proxy from active pool (NOT state-specific)
const proxy = await getRandomProxy();
setProxy(proxy.url);
```
The proxy is selected randomly from the active proxy pool. It is NOT geo-targeted to the state being crawled.
### 2. Fingerprint + Locale Harmonization
```typescript
// backend/src/platforms/dutchie/client.ts
function startSession(stateCode: string, timezone: string) {
// 1. Random browser fingerprint (Chrome/Firefox/Safari/Edge variants)
const fingerprint = getRandomFingerprint();
// 2. Match Accept-Language to proxy's timezone/location
const locale = getLocaleForTimezone(timezone);
// 3. Set headers for this session
currentSession = {
userAgent: fingerprint.ua,
acceptLanguage: locale,
secChUa: fingerprint.secChUa,
// ... other fingerprint headers
};
}
```
### Fingerprint Pool
6 browser fingerprints rotate on each session and on 403 errors:
| Browser | Version | Platform |
|---------|---------|----------|
| Chrome | 120 | Windows |
| Chrome | 120 | macOS |
| Firefox | 121 | Windows |
| Firefox | 121 | macOS |
| Safari | 17.2 | macOS |
| Edge | 120 | Windows |
### Timezone → Locale Mapping
```typescript
const TIMEZONE_TO_LOCALE: Record<string, string> = {
'America/New_York': 'en-US,en;q=0.9',
'America/Chicago': 'en-US,en;q=0.9',
'America/Denver': 'en-US,en;q=0.9',
'America/Los_Angeles': 'en-US,en;q=0.9',
'America/Phoenix': 'en-US,en;q=0.9',
// ...
};
```
---
## GraphQL Queries
### 1. getAllCitiesByState
Fetches cities with active dispensaries for a state.
```typescript
// backend/src/discovery/location-discovery.ts
const response = await executeGraphQL({
operationName: 'getAllCitiesByState',
variables: {
state: 'AZ',
countryCode: 'US'
}
});
// Returns: { cities: [{ name: 'Phoenix', slug: 'phoenix' }, ...] }
```
**Hash**: `ae547a0466ace5a48f91e55bf6699eacd87e3a42841560f0c0eabed5a0a920e6`
### 2. ConsumerDispensaries
Fetches store data for a city/state.
```typescript
// backend/src/discovery/location-discovery.ts
const response = await executeGraphQL({
operationName: 'ConsumerDispensaries',
variables: {
dispensaryFilter: {
city: 'Phoenix',
state: 'AZ',
activeOnly: true
}
}
});
// Returns: [{ id, name, address, coords, menuUrl, ... }, ...]
```
**Hash**: `0a5bfa6ca1d64ae47bcccb7c8077c87147cbc4e6982c17ceec97a2a4948b311b`
---
## Database Tables
### Discovery Tables (Staging)
| Table | Purpose |
|-------|---------|
| `dutchie_discovery_cities` | Cities known to have dispensaries |
| `dutchie_discovery_locations` | Raw discovered store data |
### Canonical Tables
| Table | Purpose |
|-------|---------|
| `dispensaries` | Promoted stores ready for crawling |
| `dispensary_crawler_profiles` | Crawler configuration per store |
| `dutchie_promotion_log` | Audit trail for all discovery actions |
---
## Validation Rules
A discovery location must have these fields to be promoted:
| Field | Requirement |
|-------|-------------|
| `platform_location_id` | MongoDB ObjectId (24 hex chars) |
| `name` | Non-empty string |
| `city` | Non-empty string |
| `state_code` | Non-empty string |
| `platform_menu_url` | Valid URL |
Invalid records are marked `status='rejected'` with errors logged.
---
## Dropped Store Detection
After discovery, the system identifies stores that may have left the Dutchie platform:
### Detection Criteria
A store is marked as "dropped" if:
1. It has a `platform_dispensary_id` (was previously verified)
2. It's currently `status='open'` and `crawl_enabled=true`
3. It was NOT seen in the latest discovery (not in `dutchie_discovery_locations` with `last_seen_at` in last 24 hours)
### Implementation
```typescript
// backend/src/discovery/discovery-crawler.ts
export async function detectDroppedStores(pool: Pool, stateCode?: string) {
// 1. Find dispensaries not in recent discovery
// 2. Mark status='dropped'
// 3. Log to dutchie_promotion_log
// 4. Return list for dashboard alert
}
```
### Admin UI
- **Dashboard**: Red alert banner when dropped stores exist
- **Dispensaries page**: Filter by `status=dropped` to review
---
## CLI Usage
```bash
# Discover all stores in a state
npx tsx src/scripts/run-discovery.ts discover:state AZ
# Discover all US states
npx tsx src/scripts/run-discovery.ts discover:all
# Dry run (no DB writes)
npx tsx src/scripts/run-discovery.ts discover:state CA --dry-run
# Check stats
npx tsx src/scripts/run-discovery.ts stats
```
---
## Rate Limiting
- **2 seconds** between city requests
- **Exponential backoff** on 429/403 responses
- **Fingerprint rotation** on 403 errors
---
## Error Handling
| Error | Action |
|-------|--------|
| 403 Forbidden | Rotate fingerprint, retry |
| 429 Rate Limited | Wait 30s, retry |
| Network timeout | Retry up to 3 times |
| GraphQL error | Log and continue to next city |
---
## Monitoring
### Logs
Discovery progress is logged to stdout:
```
[Discovery] Starting discovery for state: AZ
[Discovery] Step 1: Initializing proxy...
[Discovery] Step 2: Fetching cities...
[Discovery] Found 45 cities for AZ
[Discovery] Step 3: Discovering locations...
[Discovery] City 1/45: Phoenix - found 28 stores
...
[Discovery] Step 4: Auto-promoting discovered locations...
[Discovery] Created: 5 new dispensaries
[Discovery] Updated: 40 existing dispensaries
[Discovery] Step 5: Detecting dropped stores...
[Discovery] Found 2 dropped stores
```
### Audit Log
All actions logged to `dutchie_promotion_log`:
| Action | Description |
|--------|-------------|
| `promoted_create` | New dispensary created |
| `promoted_update` | Existing dispensary updated |
| `rejected` | Validation failed |
| `dropped` | Store not found in discovery |
---
## Next: Phase 2
See `docs/PRODUCT_CRAWL_V2.md` for the product crawling phase (coming next).

408
docs/WORKER_SYSTEM.md Normal file
View File

@@ -0,0 +1,408 @@
# CannaiQ Worker System
## Overview
The Worker System is a role-based task queue that processes background jobs. All tasks go into a single pool, and workers claim tasks based on their assigned role.
---
## Design Pattern: Single Pool, Role-Based Claiming
```
┌─────────────────────────────────────────┐
│ TASK POOL (worker_tasks) │
│ │
│ ┌─────────────────────────────────┐ │
│ │ role=store_discovery pending │ │
│ │ role=product_resync pending │ │
│ │ role=product_resync pending │ │
│ │ role=product_resync pending │ │
│ │ role=analytics_refresh pending │ │
│ │ role=entry_point_disc pending │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────────┘
┌────────────────────────────┼────────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ WORKER │ │ WORKER │ │ WORKER │
│ role=product_ │ │ role=product_ │ │ role=store_ │
│ resync │ │ resync │ │ discovery │
│ │ │ │ │ │
│ Claims ONLY │ │ Claims ONLY │ │ Claims ONLY │
│ product_resync │ │ product_resync │ │ store_discovery │
│ tasks │ │ tasks │ │ tasks │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
**Key Points:**
- All tasks go into ONE table (`worker_tasks`)
- Each worker is assigned ONE role at startup
- Workers only claim tasks matching their role
- Multiple workers can share the same role (horizontal scaling)
---
## Worker Roles
| Role | Purpose | Per-Store? | Schedule |
|------|---------|------------|----------|
| `store_discovery` | Find new dispensaries via GraphQL | No | Weekly |
| `entry_point_discovery` | Resolve platform IDs from menu URLs | Yes | On-demand |
| `product_discovery` | Initial product fetch for new stores | Yes | On-demand |
| `product_resync` | Regular price/stock updates | Yes | Every 4 hours |
| `analytics_refresh` | Refresh materialized views | No | Daily |
---
## Task Lifecycle
```
pending → claimed → running → completed
failed
(retry if < max_retries)
```
| Status | Meaning |
|--------|---------|
| `pending` | Waiting to be claimed |
| `claimed` | Worker has claimed, not yet started |
| `running` | Worker is actively processing |
| `completed` | Successfully finished |
| `failed` | Error occurred |
| `stale` | Worker died (heartbeat timeout) |
---
## Task Chaining
Tasks automatically create follow-up tasks:
```
store_discovery (finds new stores)
├─ Returns newStoreIds[] in result
entry_point_discovery (for each new store)
├─ Resolves platform_dispensary_id
product_discovery (initial crawl)
(store enters regular schedule)
product_resync (every 4 hours)
```
---
## How Claiming Works
### 1. Worker starts with a role
```bash
WORKER_ROLE=product_resync npx tsx src/tasks/task-worker.ts
```
### 2. Worker loop polls for tasks
```typescript
// Simplified worker loop
while (running) {
const task = await claimTask(this.role, this.workerId);
if (!task) {
await sleep(5000); // No tasks, wait 5 seconds
continue;
}
await processTask(task);
}
```
### 3. SQL function claims atomically
```sql
-- claim_task(role, worker_id)
UPDATE worker_tasks
SET status = 'claimed', worker_id = $2, claimed_at = NOW()
WHERE id = (
SELECT id FROM worker_tasks
WHERE role = $1 -- Filter by worker's role
AND status = 'pending'
AND (scheduled_for IS NULL OR scheduled_for <= NOW())
AND dispensary_id NOT IN ( -- Per-store locking
SELECT dispensary_id FROM worker_tasks
WHERE status IN ('claimed', 'running')
)
ORDER BY priority DESC, created_at ASC -- Priority ordering
LIMIT 1
FOR UPDATE SKIP LOCKED -- Atomic, no race conditions
)
RETURNING *;
```
**Key Features:**
- `FOR UPDATE SKIP LOCKED` - Prevents race conditions between workers
- Role filtering - Worker only sees tasks for its role
- Per-store locking - Only one active task per dispensary
- Priority ordering - Higher priority tasks first
- Scheduled tasks - Respects `scheduled_for` timestamp
---
## Heartbeat & Stale Recovery
Workers send heartbeats every 30 seconds while processing:
```typescript
// During task processing
setInterval(() => {
await pool.query(
'UPDATE worker_tasks SET last_heartbeat_at = NOW() WHERE id = $1',
[taskId]
);
}, 30000);
```
If a worker dies, its tasks are recovered:
```sql
-- recover_stale_tasks(threshold_minutes)
UPDATE worker_tasks
SET status = 'pending', worker_id = NULL, retry_count = retry_count + 1
WHERE status IN ('claimed', 'running')
AND last_heartbeat_at < NOW() - INTERVAL '10 minutes'
AND retry_count < max_retries;
```
---
## Scheduling
### Daily Resync Generation
```sql
SELECT generate_resync_tasks(6, CURRENT_DATE); -- 6 batches = every 4 hours
```
Creates staggered tasks:
| Batch | Time | Stores |
|-------|------|--------|
| 1 | 00:00 | 1-50 |
| 2 | 04:00 | 51-100 |
| 3 | 08:00 | 101-150 |
| 4 | 12:00 | 151-200 |
| 5 | 16:00 | 201-250 |
| 6 | 20:00 | 251-300 |
---
## Files
### Core
| File | Purpose |
|------|---------|
| `src/tasks/task-service.ts` | Task CRUD, claiming, capacity metrics |
| `src/tasks/task-worker.ts` | Worker loop, heartbeat, handler dispatch |
| `src/routes/tasks.ts` | REST API endpoints |
| `migrations/074_worker_task_queue.sql` | Database schema + SQL functions |
### Handlers
| File | Role |
|------|------|
| `src/tasks/handlers/store-discovery.ts` | `store_discovery` |
| `src/tasks/handlers/entry-point-discovery.ts` | `entry_point_discovery` |
| `src/tasks/handlers/product-discovery.ts` | `product_discovery` |
| `src/tasks/handlers/product-resync.ts` | `product_resync` |
| `src/tasks/handlers/analytics-refresh.ts` | `analytics_refresh` |
---
## Running Workers
### Local Development
```bash
# Start a single worker
WORKER_ROLE=product_resync npx tsx src/tasks/task-worker.ts
# Start multiple workers (different terminals)
WORKER_ROLE=product_resync WORKER_ID=resync-1 npx tsx src/tasks/task-worker.ts
WORKER_ROLE=product_resync WORKER_ID=resync-2 npx tsx src/tasks/task-worker.ts
WORKER_ROLE=store_discovery npx tsx src/tasks/task-worker.ts
```
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `WORKER_ROLE` | (required) | Which task role to process |
| `WORKER_ID` | auto-generated | Custom worker identifier |
| `POLL_INTERVAL_MS` | 5000 | How often to check for tasks |
| `HEARTBEAT_INTERVAL_MS` | 30000 | How often to update heartbeat |
### Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: task-worker-resync
spec:
replicas: 5 # Scale horizontally
template:
spec:
containers:
- name: worker
image: code.cannabrands.app/creationshop/dispensary-scraper:latest
command: ["npx", "tsx", "src/tasks/task-worker.ts"]
env:
- name: WORKER_ROLE
value: "product_resync"
```
---
## API Endpoints
### Task Management
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tasks` | List tasks (with filters) |
| POST | `/api/tasks` | Create a task |
| GET | `/api/tasks/:id` | Get task by ID |
| GET | `/api/tasks/counts` | Counts by status |
| GET | `/api/tasks/capacity` | Capacity metrics |
| POST | `/api/tasks/recover-stale` | Recover dead worker tasks |
### Task Generation
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/tasks/generate/resync` | Generate daily resync batch |
| POST | `/api/tasks/generate/discovery` | Create store discovery task |
---
## Capacity Planning
The `v_worker_capacity` view provides metrics:
```sql
SELECT * FROM v_worker_capacity;
```
| Metric | Description |
|--------|-------------|
| `pending_tasks` | Tasks waiting |
| `ready_tasks` | Tasks ready now (scheduled_for passed) |
| `running_tasks` | Tasks being processed |
| `active_workers` | Workers with recent heartbeat |
| `tasks_per_worker_hour` | Throughput estimate |
| `estimated_hours_to_drain` | Time to clear queue |
### Scaling API
```bash
GET /api/tasks/capacity/product_resync
```
```json
{
"pending_tasks": 500,
"active_workers": 3,
"workers_needed": {
"for_1_hour": 10,
"for_4_hours": 3,
"for_8_hours": 2
}
}
```
---
## Database Schema
### worker_tasks
```sql
CREATE TABLE worker_tasks (
id SERIAL PRIMARY KEY,
-- Task identification
role VARCHAR(50) NOT NULL,
dispensary_id INTEGER REFERENCES dispensaries(id),
platform VARCHAR(20),
-- State
status VARCHAR(20) DEFAULT 'pending',
priority INTEGER DEFAULT 0,
scheduled_for TIMESTAMPTZ,
-- Ownership
worker_id VARCHAR(100),
claimed_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
last_heartbeat_at TIMESTAMPTZ,
-- Results
result JSONB,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
### Key Indexes
```sql
-- Fast claiming by role
CREATE INDEX idx_worker_tasks_pending
ON worker_tasks(role, priority DESC, created_at ASC)
WHERE status = 'pending';
-- Prevent duplicate active tasks per store
CREATE UNIQUE INDEX idx_worker_tasks_unique_active_store
ON worker_tasks(dispensary_id)
WHERE status IN ('claimed', 'running') AND dispensary_id IS NOT NULL;
```
---
## Monitoring
### Logs
```
[TaskWorker] Starting worker worker-product_resync-a1b2c3d4 for role: product_resync
[TaskWorker] Claimed task 123 (product_resync) for dispensary 456
[TaskWorker] Task 123 completed successfully
```
### Health Check
```sql
-- Active workers
SELECT worker_id, role, COUNT(*), MAX(last_heartbeat_at)
FROM worker_tasks
WHERE last_heartbeat_at > NOW() - INTERVAL '5 minutes'
GROUP BY worker_id, role;
-- Task counts by role/status
SELECT role, status, COUNT(*)
FROM worker_tasks
GROUP BY role, status;
```

View File

@@ -33,8 +33,8 @@ or overwrites of existing data.
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `dispensaries` | Store locations | id, name, slug, city, state, platform_dispensary_id |
| `dutchie_products` | Canonical products | id, dispensary_id, external_product_id, name, brand_name, stock_status |
| `dutchie_product_snapshots` | Historical snapshots | dutchie_product_id, crawled_at, rec_min_price_cents |
| `store_products` | Canonical products | id, dispensary_id, external_product_id, name, brand_name, stock_status |
| `store_product_snapshots` | Historical snapshots | store_product_id, crawled_at, rec_min_price_cents |
| `brands` (view: v_brands) | Derived from products | brand_name, brand_id, product_count |
| `categories` (view: v_categories) | Derived from products | type, subcategory, product_count |
@@ -147,12 +147,10 @@ CREATE TABLE IF NOT EXISTS products_from_legacy (
---
### 3. Dutchie Products
### 3. Products (Legacy dutchie_products)
**Source:** `dutchie_legacy.dutchie_products`
**Target:** `cannaiq.dutchie_products`
These tables have nearly identical schemas. The mapping is direct:
**Target:** `cannaiq.store_products`
| Legacy Column | Canonical Column | Notes |
|---------------|------------------|-------|
@@ -180,15 +178,15 @@ ON CONFLICT (dispensary_id, external_product_id) DO NOTHING
---
### 4. Dutchie Product Snapshots
### 4. Product Snapshots (Legacy dutchie_product_snapshots)
**Source:** `dutchie_legacy.dutchie_product_snapshots`
**Target:** `cannaiq.dutchie_product_snapshots`
**Target:** `cannaiq.store_product_snapshots`
| Legacy Column | Canonical Column | Notes |
|---------------|------------------|-------|
| id | - | Generate new |
| dutchie_product_id | dutchie_product_id | Map via product lookup |
| dutchie_product_id | store_product_id | Map via product lookup |
| dispensary_id | dispensary_id | Map via dispensary lookup |
| crawled_at | crawled_at | Direct |
| rec_min_price_cents | rec_min_price_cents | Direct |
@@ -201,7 +199,7 @@ ON CONFLICT (dispensary_id, external_product_id) DO NOTHING
```sql
-- No unique constraint on snapshots - all are historical records
-- Just INSERT, no conflict handling needed
INSERT INTO dutchie_product_snapshots (...) VALUES (...)
INSERT INTO store_product_snapshots (...) VALUES (...)
```
---

View File

@@ -288,3 +288,89 @@ export async function getStates() {
return [];
}
}
// ============================================================
// PRODUCTS
// ============================================================
/**
* Fetch products for a specific dispensary
* @param {number} dispensaryId - Dispensary ID
* @param {Object} params - Query parameters
* @returns {Promise<{products: Array, pagination: Object}>}
*/
export async function getDispensaryProducts(dispensaryId, params = {}) {
const queryParams = new URLSearchParams();
if (params.category) queryParams.append('category', params.category);
if (params.brand) queryParams.append('brand', params.brand);
if (params.search) queryParams.append('search', params.search);
if (params.inStockOnly) queryParams.append('in_stock_only', 'true');
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/${dispensaryId}/products${queryString ? `?${queryString}` : ''}`;
return apiRequest(endpoint);
}
/**
* Get categories available at a dispensary
* @param {number} dispensaryId - Dispensary ID
* @returns {Promise<Array>}
*/
export async function getDispensaryCategories(dispensaryId) {
return apiRequest(`/api/v1/dispensaries/${dispensaryId}/categories`);
}
/**
* Get brands available at a dispensary
* @param {number} dispensaryId - Dispensary ID
* @returns {Promise<Array>}
*/
export async function getDispensaryBrands(dispensaryId) {
return apiRequest(`/api/v1/dispensaries/${dispensaryId}/brands`);
}
/**
* Map API product to UI format
* @param {Object} apiProduct - Product from API
* @returns {Object} - Product formatted for UI
*/
export function mapProductForUI(apiProduct) {
const p = apiProduct;
// Parse price from string or number
const parsePrice = (val) => {
if (val === null || val === undefined) return null;
const num = typeof val === 'string' ? parseFloat(val) : val;
return isNaN(num) ? null : num;
};
return {
id: p.id,
name: p.name || p.name_raw,
brand: p.brand || p.brand_name || p.brand_name_raw,
category: p.category || p.type || p.category_raw,
subcategory: p.subcategory || p.subcategory_raw,
strainType: p.strain_type,
image: p.image_url || p.primary_image_url,
thc: p.thc || p.thc_percent || p.thc_percentage,
cbd: p.cbd || p.cbd_percent || p.cbd_percentage,
price: parsePrice(p.price_rec) || parsePrice(p.regular_price) || parsePrice(p.price),
salePrice: parsePrice(p.price_rec_special) || parsePrice(p.sale_price),
inStock: p.in_stock !== undefined ? p.in_stock : p.stock_status === 'in_stock',
stockStatus: p.stock_status,
onSale: p.on_special || p.special || false,
updatedAt: p.updated_at || p.snapshot_at,
};
}
/**
* Get aggregate stats (product count, brand count, dispensary count)
* @returns {Promise<Object>}
*/
export async function getStats() {
return apiRequest('/api/v1/stats');
}

View File

@@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { MapPin, Phone, Clock, Star, Navigation, ArrowLeft, Share2, Heart, Loader2 } from 'lucide-react';
import { MapPin, Phone, Clock, Star, Navigation, ArrowLeft, Share2, Heart, Loader2, Search, Package } from 'lucide-react';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { getDispensaryBySlug, mapDispensaryForUI } from '../../api/client';
import { getDispensaryBySlug, mapDispensaryForUI, getDispensaryProducts, getDispensaryCategories, mapProductForUI } from '../../api/client';
import { formatDistance } from '../../lib/utils';
export function DispensaryDetail() {
@@ -13,6 +14,14 @@ export function DispensaryDetail() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Products state
const [products, setProducts] = useState([]);
const [categories, setCategories] = useState([]);
const [productsLoading, setProductsLoading] = useState(false);
const [selectedCategory, setSelectedCategory] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [productCount, setProductCount] = useState(0);
useEffect(() => {
const fetchDispensary = async () => {
try {
@@ -30,6 +39,35 @@ export function DispensaryDetail() {
fetchDispensary();
}, [slug]);
// Fetch products when dispensary is loaded
useEffect(() => {
if (!dispensary?.id) return;
const fetchProducts = async () => {
try {
setProductsLoading(true);
const [productsRes, categoriesRes] = await Promise.all([
getDispensaryProducts(dispensary.id, {
category: selectedCategory !== 'all' ? selectedCategory : undefined,
search: searchQuery || undefined,
limit: 50,
}),
getDispensaryCategories(dispensary.id),
]);
setProducts((productsRes.products || []).map(mapProductForUI));
setProductCount(productsRes.pagination?.total || productsRes.products?.length || 0);
setCategories(categoriesRes.categories || []);
} catch (err) {
console.error('Error fetching products:', err);
} finally {
setProductsLoading(false);
}
};
fetchProducts();
}, [dispensary?.id, selectedCategory, searchQuery]);
if (loading) {
return (
<div className="container mx-auto px-4 py-16 text-center">
@@ -158,16 +196,66 @@ export function DispensaryDetail() {
</Card>
)}
{/* Products Section Placeholder */}
{/* Products Section */}
<Card>
<CardHeader>
<CardTitle>Available Products</CardTitle>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<CardTitle>Available Products ({productCount})</CardTitle>
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search products..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Category Filters */}
<div className="flex flex-wrap gap-2 mt-4">
<Button
size="sm"
variant={selectedCategory === 'all' ? 'default' : 'outline'}
onClick={() => setSelectedCategory('all')}
>
All
</Button>
{categories.map((cat) => (
<Button
key={cat.type || cat.name}
size="sm"
variant={selectedCategory === (cat.type || cat.name) ? 'default' : 'outline'}
onClick={() => setSelectedCategory(cat.type || cat.name)}
>
{cat.type || cat.name} ({cat.count || cat.product_count || 0})
</Button>
))}
</div>
</CardHeader>
<CardContent>
<div className="text-center py-8 text-gray-500">
<p>Product menu coming soon</p>
<p className="text-sm mt-2">Connect to API to view available products</p>
</div>
{productsLoading ? (
<div className="text-center py-8">
<Loader2 className="h-8 w-8 mx-auto animate-spin text-primary" />
<p className="text-gray-500 mt-2">Loading products...</p>
</div>
) : products.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Package className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p>No products found</p>
{searchQuery && (
<Button variant="link" onClick={() => setSearchQuery('')}>
Clear search
</Button>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</CardContent>
</Card>
</div>
@@ -225,4 +313,63 @@ export function DispensaryDetail() {
);
}
// Product Card Component
function ProductCard({ product }) {
const formatPrice = (price) => {
if (price === null || price === undefined) return null;
return `$${parseFloat(price).toFixed(2)}`;
};
return (
<Card className="overflow-hidden hover:shadow-md transition-shadow">
<div className="aspect-square bg-gray-100 relative">
{product.image ? (
<img
src={product.image}
alt={product.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Package className="h-12 w-12 text-gray-300" />
</div>
)}
{product.onSale && (
<Badge className="absolute top-2 right-2 bg-red-500">Sale</Badge>
)}
{!product.inStock && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Badge variant="secondary">Out of Stock</Badge>
</div>
)}
</div>
<CardContent className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
{product.brand || 'Unknown Brand'}
</p>
<h4 className="font-medium text-gray-900 line-clamp-2 mb-2">{product.name}</h4>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
{product.category && <Badge variant="outline" className="text-xs">{product.category}</Badge>}
{product.strainType && <Badge variant="outline" className="text-xs">{product.strainType}</Badge>}
</div>
{product.thc && (
<p className="text-xs text-gray-500 mb-2">THC: {product.thc}%</p>
)}
<div className="flex items-baseline gap-2">
{product.salePrice ? (
<>
<span className="font-bold text-red-600">{formatPrice(product.salePrice)}</span>
<span className="text-sm text-gray-400 line-through">{formatPrice(product.price)}</span>
</>
) : product.price ? (
<span className="font-bold text-gray-900">{formatPrice(product.price)}</span>
) : (
<span className="text-sm text-gray-400">Price not available</span>
)}
</div>
</CardContent>
</Card>
);
}
export default DispensaryDetail;

114
findagram/FINDAGRAM.md Normal file
View File

@@ -0,0 +1,114 @@
# Findagram Development Notes
## Overview
Findagram (findagram.co) is a consumer-facing cannabis product discovery app. Users can search products across dispensaries, set price alerts, and save favorites.
## Architecture
- **Frontend**: React (Create React App) at `findagram/frontend/`
- **Backend**: Shared CannaiQ Express API at `backend/`
- **Auth**: JWT-based consumer auth via `/api/consumer/auth/*`
- **Domain**: `findagram.co` (passed in all auth requests)
## Key Files
| File | Purpose |
|------|---------|
| `src/context/AuthContext.js` | Global auth state, login/register, token management |
| `src/components/findagram/AuthModal.jsx` | Login/signup modal popup |
| `src/api/client.js` | API client for products, dispensaries, categories, brands |
| `src/api/consumer.js` | API client for favorites, alerts, saved searches (auth required) |
## Backend Consumer API Endpoints
All require JWT token in `Authorization: Bearer <token>` header.
### Auth (`/api/consumer/auth/*`)
- `POST /register` - Create account (requires `domain: 'findagram.co'`)
- `POST /login` - Login (requires `domain: 'findagram.co'`)
- `GET /me` - Get current user
- `PUT /me` - Update profile
### Favorites (`/api/consumer/favorites/*`)
- `GET /` - Get user's favorites
- `POST /` - Add favorite (`{ productId, dispensaryId? }`)
- `DELETE /:id` - Remove by favorite ID
- `DELETE /product/:productId` - Remove by product ID
- `GET /check/product/:id` - Check if product is favorited
### Alerts (`/api/consumer/alerts/*`)
- `GET /` - Get user's alerts
- `POST /` - Create alert (`{ alertType, productId, targetPrice }`)
- Alert types: `price_drop`, `back_in_stock`, `product_on_special`
- `PUT /:id` - Update alert
- `DELETE /:id` - Delete alert
- `POST /:id/toggle` - Toggle active status
### Saved Searches (`/api/consumer/saved-searches/*`)
- `GET /` - Get user's saved searches
- `POST /` - Create saved search
- `PUT /:id` - Update
- `DELETE /:id` - Delete
- `POST /:id/run` - Get search params for execution
## Database Tables (Consumer)
| Table | Purpose |
|-------|---------|
| `users` | User accounts (shared across domains via `domain` column) |
| `findagram_users` | Findagram-specific user profile data |
| `findagram_favorites` | Product favorites |
| `findagram_alerts` | Price/stock alerts |
| `findagram_saved_searches` | Saved search filters |
## Auth Flow
1. User clicks favorite/alert on a product
2. If not logged in → AuthModal opens
3. User logs in or creates account
4. JWT token stored in localStorage (`findagram_auth`)
5. Pending action (favorite/alert) executes automatically after auth
6. All subsequent API calls include `Authorization: Bearer <token>`
## Environment Variables
```bash
# Frontend (.env)
REACT_APP_API_URL=http://localhost:3010 # Local
REACT_APP_API_URL=https://cannaiq.co # Production
```
## Future: Migration to cannabrands.app
Currently uses CannaiQ backend. Later will migrate auth to cannabrands.app:
- Update `API_BASE_URL` for auth endpoints
- Keep product/dispensary API pointing to CannaiQ
- May need to sync user accounts between systems
## Important Notes
1. **Domain is critical** - All auth requests must include `domain: 'findagram.co'`
2. **Favorites are product-based** (unlike findadispo which is dispensary-based)
3. **Price alerts** require `targetPrice` for `price_drop` type
4. **Mock data** in `src/mockData.js` is no longer imported - can be safely deleted
5. **Token expiry** is 30 days (`JWT_EXPIRES_IN` in backend)
## Pages Using Real API
All pages are now wired to the real CannaiQ API:
| Page | API Endpoint | Notes |
|------|--------------|-------|
| Home | `/api/products`, `/api/dispensaries` | Featured products, deals |
| Products | `/api/products` | Search, filters, pagination |
| ProductDetail | `/api/products/:id` | Single product with dispensaries |
| Deals | `/api/products?hasSpecial=true` | Products on sale |
| Brands | `/api/brands` | Brand listing |
| BrandDetail | `/api/brands/:name` | Brand products |
| Categories | `/api/categories` | Category listing |
| CategoryDetail | `/api/products?category=...` | Category products |
| Dashboard | `/api/consumer/favorites`, alerts, searches | User dashboard (auth) |
| Favorites | `/api/consumer/favorites` | User favorites (auth) |
| Alerts | `/api/consumer/alerts` | Price alerts (auth) |
| SavedSearches | `/api/consumer/saved-searches` | Saved searches (auth) |

View File

@@ -7,16 +7,6 @@
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",

View File

@@ -1,7 +1,9 @@
import React, { useState } from 'react';
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Header from './components/findagram/Header';
import Footer from './components/findagram/Footer';
import AuthModal from './components/findagram/AuthModal';
// Pages
import Home from './pages/findagram/Home';
@@ -12,6 +14,7 @@ import Brands from './pages/findagram/Brands';
import BrandDetail from './pages/findagram/BrandDetail';
import Categories from './pages/findagram/Categories';
import CategoryDetail from './pages/findagram/CategoryDetail';
import DispensaryDetail from './pages/findagram/DispensaryDetail';
import About from './pages/findagram/About';
import Contact from './pages/findagram/Contact';
import Login from './pages/findagram/Login';
@@ -23,32 +26,11 @@ import SavedSearches from './pages/findagram/SavedSearches';
import Profile from './pages/findagram/Profile';
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState(null);
// Mock login function
const handleLogin = (email, password) => {
// In a real app, this would make an API call
setUser({
id: 1,
name: 'John Doe',
email: email,
avatar: null,
});
setIsLoggedIn(true);
return true;
};
// Mock logout function
const handleLogout = () => {
setUser(null);
setIsLoggedIn(false);
};
return (
<Router>
<div className="flex flex-col min-h-screen">
<Header isLoggedIn={isLoggedIn} user={user} onLogout={handleLogout} />
<AuthProvider>
<Router>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow">
<Routes>
@@ -61,12 +43,13 @@ function App() {
<Route path="/brands/:slug" element={<BrandDetail />} />
<Route path="/categories" element={<Categories />} />
<Route path="/categories/:slug" element={<CategoryDetail />} />
<Route path="/dispensaries/:slug" element={<DispensaryDetail />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
{/* Auth Routes */}
<Route path="/login" element={<Login onLogin={handleLogin} />} />
<Route path="/signup" element={<Signup onLogin={handleLogin} />} />
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
{/* Dashboard Routes */}
<Route path="/dashboard" element={<Dashboard />} />
@@ -77,9 +60,11 @@ function App() {
</Routes>
</main>
<Footer />
</div>
</Router>
<Footer />
<AuthModal />
</div>
</Router>
</AuthProvider>
);
}

View File

@@ -130,7 +130,7 @@ export async function getStoreProducts(storeId, params = {}) {
offset: params.offset || 0,
});
return request(`/api/v1/stores/${storeId}/products${queryString}`);
return request(`/api/v1/dispensaries/${storeId}/products${queryString}`);
}
// ============================================================
@@ -149,47 +149,49 @@ export async function getStoreProducts(storeId, params = {}) {
export async function getDispensaries(params = {}) {
const queryString = buildQueryString({
city: params.city,
state: params.state,
hasPlatformId: params.hasPlatformId,
has_products: params.hasProducts ? 'true' : undefined,
limit: params.limit || 100,
offset: params.offset || 0,
});
return request(`/api/v1/stores${queryString}`);
return request(`/api/v1/dispensaries${queryString}`);
}
/**
* Get a single dispensary by ID
*/
export async function getDispensary(id) {
return request(`/api/v1/stores/${id}`);
return request(`/api/v1/dispensaries/${id}`);
}
/**
* Get dispensary by slug or platform ID
*/
export async function getDispensaryBySlug(slug) {
return request(`/api/v1/stores/slug/${slug}`);
return request(`/api/v1/dispensaries/slug/${slug}`);
}
/**
* Get dispensary summary (product counts, categories, brands)
*/
export async function getDispensarySummary(id) {
return request(`/api/v1/stores/${id}/summary`);
return request(`/api/v1/dispensaries/${id}/summary`);
}
/**
* Get brands available at a specific dispensary
*/
export async function getDispensaryBrands(id) {
return request(`/api/v1/stores/${id}/brands`);
return request(`/api/v1/dispensaries/${id}/brands`);
}
/**
* Get categories available at a specific dispensary
*/
export async function getDispensaryCategories(id) {
return request(`/api/v1/stores/${id}/categories`);
return request(`/api/v1/dispensaries/${id}/categories`);
}
// ============================================================
@@ -224,29 +226,48 @@ export async function getBrands(params = {}) {
}
// ============================================================
// DEALS / SPECIALS
// Note: The /api/az routes don't have a dedicated specials endpoint yet.
// For now, we can filter products with sale prices or use dispensary-specific specials.
// STATS
// ============================================================
/**
* Get products on sale (products where sale_price exists)
* This is a client-side filter until a dedicated endpoint is added.
* Get aggregate stats (product count, brand count, dispensary count)
*/
export async function getDeals(params = {}) {
// For now, get products and we'll need to filter client-side
// or we could use the /api/dispensaries/:slug/specials endpoint if we have a dispensary context
const result = await getProducts({
...params,
export async function getStats() {
return request('/api/v1/stats');
}
// ============================================================
// DEALS / SPECIALS
// ============================================================
/**
* Get products on special/sale
* Uses the on_special filter parameter on the products endpoint
*
* @param {Object} params
* @param {string} [params.type] - Category type filter
* @param {string} [params.brandName] - Brand name filter
* @param {number} [params.limit=100] - Page size
* @param {number} [params.offset=0] - Offset for pagination
*/
export async function getSpecials(params = {}) {
const queryString = buildQueryString({
on_special: 'true',
type: params.type,
brandName: params.brandName,
stockStatus: params.stockStatus || 'in_stock',
limit: params.limit || 100,
offset: params.offset || 0,
});
// Filter to only products with a sale price
// Note: This is a temporary solution - ideally the backend would support this filter
return {
...result,
products: result.products.filter(p => p.sale_price || p.med_sale_price),
};
return request(`/api/v1/products${queryString}`);
}
/**
* Alias for getSpecials for backward compatibility
*/
export async function getDeals(params = {}) {
return getSpecials(params);
}
// ============================================================
@@ -278,27 +299,40 @@ export function mapProductForUI(apiProduct) {
// Handle both direct product and transformed product formats
const p = apiProduct;
// Helper to parse price (API returns strings like "29.99" or null)
const parsePrice = (val) => {
if (val === null || val === undefined) return null;
const num = typeof val === 'string' ? parseFloat(val) : val;
return isNaN(num) ? null : num;
};
const regularPrice = parsePrice(p.regular_price);
const salePrice = parsePrice(p.sale_price);
const medPrice = parsePrice(p.med_price);
const medSalePrice = parsePrice(p.med_sale_price);
const regularPriceMax = parsePrice(p.regular_price_max);
return {
id: p.id,
name: p.name,
brand: p.brand || p.brand_name,
category: p.type || p.category,
subcategory: p.subcategory,
category: p.type || p.category || p.category_raw,
subcategory: p.subcategory || p.subcategory_raw,
strainType: p.strain_type || null,
// Images
image: p.image_url || p.primary_image_url || null,
// Potency
thc: p.thc_percentage || p.thc_content || null,
cbd: p.cbd_percentage || p.cbd_content || null,
// Prices (API returns dollars as numbers or null)
price: p.regular_price || null,
priceRange: p.regular_price_max && p.regular_price
? { min: p.regular_price, max: p.regular_price_max }
// Prices (parsed to numbers)
price: regularPrice,
priceRange: regularPriceMax && regularPrice
? { min: regularPrice, max: regularPriceMax }
: null,
onSale: !!(p.sale_price || p.med_sale_price),
salePrice: p.sale_price || null,
medPrice: p.med_price || null,
medSalePrice: p.med_sale_price || null,
onSale: !!(salePrice || medSalePrice),
salePrice: salePrice,
medPrice: medPrice,
medSalePrice: medSalePrice,
// Stock
inStock: p.in_stock !== undefined ? p.in_stock : p.stock_status === 'in_stock',
stockStatus: p.stock_status,
@@ -339,10 +373,12 @@ export function mapCategoryForUI(apiCategory) {
* Map API brand to UI-compatible format
*/
export function mapBrandForUI(apiBrand) {
// API returns 'brand' field (see /api/v1/brands endpoint)
const brandName = apiBrand.brand || apiBrand.brand_name || '';
return {
id: apiBrand.brand_name,
name: apiBrand.brand_name,
slug: apiBrand.brand_name?.toLowerCase().replace(/\s+/g, '-'),
id: brandName,
name: brandName,
slug: brandName ? brandName.toLowerCase().replace(/\s+/g, '-') : '',
logo: apiBrand.brand_logo_url || null,
productCount: parseInt(apiBrand.product_count || 0, 10),
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
@@ -354,23 +390,41 @@ export function mapBrandForUI(apiBrand) {
* Map API dispensary to UI-compatible format
*/
export function mapDispensaryForUI(apiDispensary) {
// Handle location object from API (location.latitude, location.longitude)
const lat = apiDispensary.location?.latitude || apiDispensary.latitude;
const lng = apiDispensary.location?.longitude || apiDispensary.longitude;
return {
id: apiDispensary.id,
name: apiDispensary.dba_name || apiDispensary.name,
slug: apiDispensary.slug,
city: apiDispensary.city,
state: apiDispensary.state,
address: apiDispensary.address,
address: apiDispensary.address1 || apiDispensary.address,
zip: apiDispensary.zip,
latitude: apiDispensary.latitude,
longitude: apiDispensary.longitude,
latitude: lat,
longitude: lng,
website: apiDispensary.website,
menuUrl: apiDispensary.menu_url,
// Summary data (if fetched with summary)
productCount: apiDispensary.totalProducts,
imageUrl: apiDispensary.image_url,
rating: apiDispensary.rating,
reviewCount: apiDispensary.review_count,
// Product data from API
productCount: apiDispensary.product_count || apiDispensary.totalProducts || 0,
inStockCount: apiDispensary.in_stock_count || apiDispensary.inStockCount || 0,
brandCount: apiDispensary.brandCount,
categoryCount: apiDispensary.categoryCount,
inStockCount: apiDispensary.inStockCount,
// Services
services: apiDispensary.services || {
pickup: false,
delivery: false,
curbside: false
},
// License type
licenseType: apiDispensary.license_type || {
medical: false,
recreational: false
},
};
}
@@ -386,6 +440,68 @@ function formatCategoryName(type) {
.replace(/\b\w/g, c => c.toUpperCase());
}
// ============================================================
// CLICK TRACKING
// ============================================================
/**
* Get cached visitor location from sessionStorage
*/
function getCachedVisitorLocation() {
try {
const cached = sessionStorage.getItem('findagram_location');
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
// Ignore errors
}
return null;
}
/**
* Track a product click event
* Fire-and-forget - doesn't block UI
*
* @param {Object} params
* @param {string} params.productId - Product ID (required)
* @param {string} [params.storeId] - Store/dispensary ID
* @param {string} [params.brandId] - Brand name/ID
* @param {string} [params.dispensaryName] - Dispensary name
* @param {string} params.action - Action type: view, open_product, open_store, compare
* @param {string} params.source - Source identifier (e.g., 'findagram')
* @param {string} [params.pageType] - Page type (e.g., 'home', 'dispensary', 'deals')
*/
export function trackProductClick(params) {
// Get visitor's cached location
const visitorLocation = getCachedVisitorLocation();
const payload = {
product_id: String(params.productId),
store_id: params.storeId ? String(params.storeId) : undefined,
brand_id: params.brandId || undefined,
dispensary_name: params.dispensaryName || undefined,
action: params.action || 'view',
source: params.source || 'findagram',
page_type: params.pageType || undefined,
url_path: window.location.pathname,
// Visitor location from IP geolocation
visitor_city: visitorLocation?.city || undefined,
visitor_state: visitorLocation?.state || undefined,
visitor_lat: visitorLocation?.lat || undefined,
visitor_lng: visitorLocation?.lng || undefined,
};
// Fire and forget - don't await
fetch(`${API_BASE_URL}/api/events/product-click`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).catch(() => {
// Silently ignore errors - analytics shouldn't break UX
});
}
// Default export for convenience
const api = {
// Products
@@ -405,13 +521,18 @@ const api = {
// Categories & Brands
getCategories,
getBrands,
// Stats
getStats,
// Deals
getDeals,
getSpecials,
// Mappers
mapProductForUI,
mapCategoryForUI,
mapBrandForUI,
mapDispensaryForUI,
// Tracking
trackProductClick,
};
export default api;

View File

@@ -0,0 +1,302 @@
/**
* Consumer API Client for Findagram
*
* Handles authenticated requests for:
* - Favorites
* - Price Alerts
* - Saved Searches
*
* All methods require auth token (use with AuthContext's authFetch)
*/
// ============================================================
// FAVORITES
// ============================================================
/**
* Get all user's favorites
* @param {Function} authFetch - Authenticated fetch from AuthContext
*/
export async function getFavorites(authFetch) {
return authFetch('/api/consumer/favorites');
}
/**
* Add product to favorites
* @param {Function} authFetch
* @param {number} productId
* @param {number} [dispensaryId] - Optional dispensary context
*/
export async function addFavorite(authFetch, productId, dispensaryId = null) {
return authFetch('/api/consumer/favorites', {
method: 'POST',
body: JSON.stringify({ productId, dispensaryId }),
});
}
/**
* Remove favorite by favorite ID
* @param {Function} authFetch
* @param {number} favoriteId
*/
export async function removeFavorite(authFetch, favoriteId) {
return authFetch(`/api/consumer/favorites/${favoriteId}`, {
method: 'DELETE',
});
}
/**
* Remove favorite by product ID
* @param {Function} authFetch
* @param {number} productId
*/
export async function removeFavoriteByProduct(authFetch, productId) {
return authFetch(`/api/consumer/favorites/product/${productId}`, {
method: 'DELETE',
});
}
/**
* Check if product is favorited
* @param {Function} authFetch
* @param {number} productId
* @returns {Promise<{isFavorited: boolean}>}
*/
export async function checkFavorite(authFetch, productId) {
return authFetch(`/api/consumer/favorites/check/product/${productId}`);
}
// ============================================================
// ALERTS
// ============================================================
/**
* Get all user's alerts
* @param {Function} authFetch
*/
export async function getAlerts(authFetch) {
return authFetch('/api/consumer/alerts');
}
/**
* Get alert statistics
* @param {Function} authFetch
*/
export async function getAlertStats(authFetch) {
return authFetch('/api/consumer/alerts/stats');
}
/**
* Create a price drop alert
* @param {Function} authFetch
* @param {Object} params
* @param {number} params.productId - Product to track
* @param {number} params.targetPrice - Price to alert at
* @param {number} [params.dispensaryId] - Optional dispensary context
*/
export async function createPriceAlert(authFetch, { productId, targetPrice, dispensaryId }) {
return authFetch('/api/consumer/alerts', {
method: 'POST',
body: JSON.stringify({
alertType: 'price_drop',
productId,
targetPrice,
dispensaryId,
}),
});
}
/**
* Create a back-in-stock alert
* @param {Function} authFetch
* @param {Object} params
* @param {number} params.productId - Product to track
* @param {number} [params.dispensaryId] - Optional dispensary context
*/
export async function createStockAlert(authFetch, { productId, dispensaryId }) {
return authFetch('/api/consumer/alerts', {
method: 'POST',
body: JSON.stringify({
alertType: 'back_in_stock',
productId,
dispensaryId,
}),
});
}
/**
* Create a brand/category alert
* @param {Function} authFetch
* @param {Object} params
* @param {string} [params.brand] - Brand to track
* @param {string} [params.category] - Category to track
*/
export async function createBrandCategoryAlert(authFetch, { brand, category }) {
return authFetch('/api/consumer/alerts', {
method: 'POST',
body: JSON.stringify({
alertType: 'product_on_special',
brand,
category,
}),
});
}
/**
* Update an alert
* @param {Function} authFetch
* @param {number} alertId
* @param {Object} updates
* @param {boolean} [updates.isActive]
* @param {number} [updates.targetPrice]
*/
export async function updateAlert(authFetch, alertId, updates) {
return authFetch(`/api/consumer/alerts/${alertId}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
/**
* Toggle alert active status
* @param {Function} authFetch
* @param {number} alertId
*/
export async function toggleAlert(authFetch, alertId) {
return authFetch(`/api/consumer/alerts/${alertId}/toggle`, {
method: 'POST',
});
}
/**
* Delete an alert
* @param {Function} authFetch
* @param {number} alertId
*/
export async function deleteAlert(authFetch, alertId) {
return authFetch(`/api/consumer/alerts/${alertId}`, {
method: 'DELETE',
});
}
// ============================================================
// SAVED SEARCHES
// ============================================================
/**
* Get all user's saved searches
* @param {Function} authFetch
*/
export async function getSavedSearches(authFetch) {
return authFetch('/api/consumer/saved-searches');
}
/**
* Create a saved search
* @param {Function} authFetch
* @param {Object} params
* @param {string} params.name - Display name
* @param {string} [params.query] - Search query
* @param {string} [params.category] - Category filter
* @param {string} [params.brand] - Brand filter
* @param {string} [params.strainType] - Strain type filter
* @param {number} [params.minPrice] - Min price filter
* @param {number} [params.maxPrice] - Max price filter
* @param {number} [params.minThc] - Min THC filter
* @param {number} [params.maxThc] - Max THC filter
* @param {boolean} [params.notifyOnNew] - Notify on new products
* @param {boolean} [params.notifyOnPriceDrop] - Notify on price drops
*/
export async function createSavedSearch(authFetch, params) {
return authFetch('/api/consumer/saved-searches', {
method: 'POST',
body: JSON.stringify(params),
});
}
/**
* Update a saved search
* @param {Function} authFetch
* @param {number} searchId
* @param {Object} updates
*/
export async function updateSavedSearch(authFetch, searchId, updates) {
return authFetch(`/api/consumer/saved-searches/${searchId}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
}
/**
* Delete a saved search
* @param {Function} authFetch
* @param {number} searchId
*/
export async function deleteSavedSearch(authFetch, searchId) {
return authFetch(`/api/consumer/saved-searches/${searchId}`, {
method: 'DELETE',
});
}
/**
* Run a saved search (get search params)
* @param {Function} authFetch
* @param {number} searchId
* @returns {Promise<{searchParams: Object, searchUrl: string}>}
*/
export async function runSavedSearch(authFetch, searchId) {
return authFetch(`/api/consumer/saved-searches/${searchId}/run`, {
method: 'POST',
});
}
// ============================================================
// HELPER: Generate search name from filters
// ============================================================
/**
* Generate a display name for a search based on filters
* @param {Object} filters
* @returns {string}
*/
export function generateSearchName(filters) {
const parts = [];
if (filters.query || filters.search) parts.push(`"${filters.query || filters.search}"`);
if (filters.category || filters.type) parts.push(filters.category || filters.type);
if (filters.brand || filters.brandName) parts.push(filters.brand || filters.brandName);
if (filters.strainType) parts.push(filters.strainType);
if (filters.maxPrice) parts.push(`Under $${filters.maxPrice}`);
if (filters.minThc) parts.push(`${filters.minThc}%+ THC`);
return parts.length > 0 ? parts.join(' - ') : 'All Products';
}
// Default export
const consumerApi = {
// Favorites
getFavorites,
addFavorite,
removeFavorite,
removeFavoriteByProduct,
checkFavorite,
// Alerts
getAlerts,
getAlertStats,
createPriceAlert,
createStockAlert,
createBrandCategoryAlert,
updateAlert,
toggleAlert,
deleteAlert,
// Saved Searches
getSavedSearches,
createSavedSearch,
updateSavedSearch,
deleteSavedSearch,
runSavedSearch,
// Helpers
generateSearchName,
};
export default consumerApi;

View File

@@ -0,0 +1,315 @@
/**
* AuthModal - Login/Signup modal for Findagram
*
* Shows when user tries to:
* - Favorite a product
* - Set a price alert
* - Save a search
* - Access dashboard features
*/
import React, { useState } from 'react';
import { useAuth } from '../../context/AuthContext';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../ui/card';
import { Button } from '../ui/button';
import { X, Mail, Lock, User, Phone, MapPin, Loader2, Eye, EyeOff } from 'lucide-react';
const AuthModal = () => {
const {
showAuthModal,
authModalMode,
setAuthModalMode,
closeAuthModal,
login,
register,
} = useAuth();
const [formData, setFormData] = useState({
email: '',
password: '',
firstName: '',
lastName: '',
phone: '',
city: '',
state: '',
});
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
if (!showAuthModal) return null;
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
setError('');
};
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
if (authModalMode === 'login') {
await login(formData.email, formData.password);
} else {
// Validate signup fields
if (!formData.firstName || !formData.lastName) {
throw new Error('First and last name are required');
}
if (formData.password.length < 6) {
throw new Error('Password must be at least 6 characters');
}
await register(formData);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const switchMode = () => {
setAuthModalMode(authModalMode === 'login' ? 'signup' : 'login');
setError('');
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={closeAuthModal}
/>
{/* Modal */}
<Card className="relative w-full max-w-md bg-white shadow-2xl animate-in fade-in zoom-in duration-200">
{/* Close button */}
<button
onClick={closeAuthModal}
className="absolute top-4 right-4 p-1 rounded-full hover:bg-gray-100 transition-colors"
>
<X className="h-5 w-5 text-gray-500" />
</button>
<CardHeader className="text-center pb-2">
<div className="mx-auto w-12 h-12 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center mb-4">
<User className="h-6 w-6 text-white" />
</div>
<CardTitle>
{authModalMode === 'login' ? 'Welcome Back' : 'Create Account'}
</CardTitle>
<CardDescription>
{authModalMode === 'login'
? 'Sign in to save favorites and set price alerts'
: 'Join Findagram to track products and get notified of deals'}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Error message */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Signup only fields */}
{authModalMode === 'signup' && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
First Name
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="John"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Last Name
</label>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Doe"
required
/>
</div>
</div>
</>
)}
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="you@example.com"
required
/>
</div>
</div>
{/* Password */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={formData.password}
onChange={handleChange}
className="w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder={authModalMode === 'signup' ? 'Min 6 characters' : 'Your password'}
required
minLength={authModalMode === 'signup' ? 6 : undefined}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{/* Signup only: Phone & Location */}
{authModalMode === 'signup' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone <span className="text-gray-400">(optional)</span>
</label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="(555) 123-4567"
/>
</div>
<p className="text-xs text-gray-500 mt-1">For SMS alerts about price drops</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
City <span className="text-gray-400">(optional)</span>
</label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
name="city"
value={formData.city}
onChange={handleChange}
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Phoenix"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
State
</label>
<select
name="state"
value={formData.state}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Select...</option>
<option value="AZ">Arizona</option>
<option value="CA">California</option>
<option value="CO">Colorado</option>
<option value="MI">Michigan</option>
<option value="NV">Nevada</option>
<option value="OR">Oregon</option>
<option value="WA">Washington</option>
</select>
</div>
</div>
</>
)}
{/* Submit button */}
<Button
type="submit"
disabled={loading}
className="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 text-white py-2.5"
>
{loading ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
{authModalMode === 'login' ? 'Signing in...' : 'Creating account...'}
</>
) : (
authModalMode === 'login' ? 'Sign In' : 'Create Account'
)}
</Button>
{/* Switch mode link */}
<div className="text-center text-sm text-gray-600">
{authModalMode === 'login' ? (
<>
Don't have an account?{' '}
<button
type="button"
onClick={switchMode}
className="text-purple-600 hover:text-purple-700 font-medium"
>
Sign up
</button>
</>
) : (
<>
Already have an account?{' '}
<button
type="button"
onClick={switchMode}
className="text-purple-600 hover:text-purple-700 font-medium"
>
Sign in
</button>
</>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export default AuthModal;

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import {
@@ -27,7 +28,8 @@ import {
Store,
} from 'lucide-react';
const Header = ({ isLoggedIn = false, user = null }) => {
const Header = () => {
const { isAuthenticated, user, logout, openAuthModal } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const location = useLocation();
@@ -99,7 +101,7 @@ const Header = ({ isLoggedIn = false, user = null }) => {
{/* Right side actions */}
<div className="flex items-center space-x-4">
{isLoggedIn ? (
{isAuthenticated ? (
<>
{/* Favorites */}
<Link to="/dashboard/favorites" className="hidden sm:block">
@@ -121,9 +123,9 @@ const Header = ({ isLoggedIn = false, user = null }) => {
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
<Avatar className="h-10 w-10 border-2 border-primary">
<AvatarImage src={user?.avatar} alt={user?.name} />
<AvatarImage src={user?.avatar} alt={user?.firstName} />
<AvatarFallback className="bg-primary text-white">
{user?.name?.charAt(0) || 'U'}
{user?.firstName?.charAt(0) || 'U'}
</AvatarFallback>
</Avatar>
</Button>
@@ -131,9 +133,11 @@ const Header = ({ isLoggedIn = false, user = null }) => {
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user?.name || 'User'}</p>
<p className="text-sm font-medium leading-none">
{user?.firstName} {user?.lastName}
</p>
<p className="text-xs leading-none text-muted-foreground">
{user?.email || 'user@example.com'}
{user?.email}
</p>
</div>
</DropdownMenuLabel>
@@ -169,7 +173,10 @@ const Header = ({ isLoggedIn = false, user = null }) => {
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-red-600">
<DropdownMenuItem
className="text-red-600 cursor-pointer"
onClick={logout}
>
<LogOut className="mr-2 h-4 w-4" />
Log out
</DropdownMenuItem>
@@ -178,16 +185,19 @@ const Header = ({ isLoggedIn = false, user = null }) => {
</>
) : (
<>
<Link to="/login" className="hidden sm:block">
<Button variant="ghost" className="text-gray-600">
Log in
</Button>
</Link>
<Link to="/signup">
<Button className="gradient-purple text-white hover:opacity-90">
Sign up
</Button>
</Link>
<Button
variant="ghost"
className="hidden sm:block text-gray-600"
onClick={() => openAuthModal('login')}
>
Log in
</Button>
<Button
className="gradient-purple text-white hover:opacity-90"
onClick={() => openAuthModal('signup')}
>
Sign up
</Button>
</>
)}
@@ -241,7 +251,7 @@ const Header = ({ isLoggedIn = false, user = null }) => {
<span className="font-medium">{item.name}</span>
</Link>
))}
{isLoggedIn && (
{isAuthenticated && (
<>
<div className="border-t border-gray-200 my-2" />
<Link

View File

@@ -1,16 +1,59 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Card, CardContent } from '../ui/card';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Heart, Star, MapPin, TrendingDown } from 'lucide-react';
import { Heart, Star, MapPin, TrendingDown, Loader2 } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { addFavorite, removeFavoriteByProduct, checkFavorite } from '../../api/consumer';
import { trackProductClick } from '../../api/client';
const ProductCard = ({
product,
onFavorite,
isFavorite = false,
showDispensaryCount = true
onFavoriteChange,
initialIsFavorite,
showDispensaryCount = true,
pageType = 'browse'
}) => {
const location = useLocation();
const { isAuthenticated, requireAuth, authFetch } = useAuth();
const [isFavorite, setIsFavorite] = useState(initialIsFavorite || false);
const [favoriteLoading, setFavoriteLoading] = useState(false);
// Check favorite status on mount if authenticated
useEffect(() => {
if (isAuthenticated && product?.id && initialIsFavorite === undefined) {
checkFavorite(authFetch, product.id)
.then(data => setIsFavorite(data.isFavorited))
.catch(() => {}); // Ignore errors
}
}, [isAuthenticated, product?.id, authFetch, initialIsFavorite]);
const handleFavoriteClick = async (e) => {
e.preventDefault();
e.stopPropagation();
// If not authenticated, show auth modal with pending action
if (!requireAuth(() => handleFavoriteClick({ preventDefault: () => {}, stopPropagation: () => {} }))) {
return;
}
setFavoriteLoading(true);
try {
if (isFavorite) {
await removeFavoriteByProduct(authFetch, product.id);
setIsFavorite(false);
} else {
await addFavorite(authFetch, product.id, product.dispensaryId);
setIsFavorite(true);
}
onFavoriteChange?.(product.id, !isFavorite);
} catch (error) {
console.error('Failed to update favorite:', error);
} finally {
setFavoriteLoading(false);
}
};
const {
id,
name,
@@ -35,11 +78,24 @@ const ProductCard = ({
hybrid: 'bg-green-100 text-green-800',
};
const savings = onSale && salePrice ? ((price - salePrice) / price * 100).toFixed(0) : 0;
const savings = onSale && salePrice && price ? ((price - salePrice) / price * 100).toFixed(0) : 0;
// Track product click
const handleProductClick = () => {
trackProductClick({
productId: id,
storeId: product.dispensaryId,
brandId: brand,
dispensaryName: product.storeName,
action: 'open_product',
source: 'findagram',
pageType: pageType || location.pathname.split('/')[1] || 'home',
});
};
return (
<Card className="product-card group overflow-hidden">
<Link to={`/products/${id}`}>
<Link to={`/products/${id}`} onClick={handleProductClick}>
{/* Image Container */}
<div className="relative aspect-square overflow-hidden bg-gray-100">
<img
@@ -70,13 +126,14 @@ const ProductCard = ({
className={`absolute top-3 right-3 h-8 w-8 rounded-full bg-white/80 hover:bg-white ${
isFavorite ? 'text-red-500' : 'text-gray-400'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFavorite?.(id);
}}
onClick={handleFavoriteClick}
disabled={favoriteLoading}
>
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
{favoriteLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Heart className={`h-4 w-4 ${isFavorite ? 'fill-current' : ''}`} />
)}
</Button>
</div>
</Link>
@@ -88,7 +145,7 @@ const ProductCard = ({
</p>
{/* Product Name */}
<Link to={`/products/${id}`}>
<Link to={`/products/${id}`} onClick={handleProductClick}>
<h3 className="font-semibold text-gray-900 line-clamp-2 hover:text-primary transition-colors mb-2">
{name}
</h3>
@@ -124,27 +181,31 @@ const ProductCard = ({
</div>
)}
{/* Price */}
<div className="flex items-baseline gap-2 mb-3">
{onSale && salePrice ? (
<>
<span className="text-lg font-bold text-pink-600">
${salePrice.toFixed(2)}
{/* Price - only show if we have price data */}
{(price != null || salePrice != null || priceRange != null) && (
<div className="flex items-baseline gap-2 mb-3">
{onSale && salePrice ? (
<>
<span className="text-lg font-bold text-pink-600">
${salePrice.toFixed(2)}
</span>
{price && (
<span className="text-sm text-gray-400 line-through">
${price.toFixed(2)}
</span>
)}
</>
) : priceRange && priceRange.min != null && priceRange.max != null ? (
<span className="text-lg font-bold text-gray-900">
${priceRange.min.toFixed(2)} - ${priceRange.max.toFixed(2)}
</span>
<span className="text-sm text-gray-400 line-through">
) : price != null ? (
<span className="text-lg font-bold text-gray-900">
${price.toFixed(2)}
</span>
</>
) : priceRange ? (
<span className="text-lg font-bold text-gray-900">
${priceRange.min.toFixed(2)} - ${priceRange.max.toFixed(2)}
</span>
) : (
<span className="text-lg font-bold text-gray-900">
${price.toFixed(2)}
</span>
)}
</div>
) : null}
</div>
)}
{/* Dispensary Count */}
{showDispensaryCount && dispensaries.length > 0 && (

View File

@@ -0,0 +1,258 @@
/**
* AuthContext - Global authentication state for Findagram
*
* Manages user login state, JWT token, and provides auth methods.
* Persists auth state in localStorage for session continuity.
*/
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
const AuthContext = createContext(null);
const API_BASE_URL = process.env.REACT_APP_API_URL || '';
const STORAGE_KEY = 'findagram_auth';
const DOMAIN = 'findagram.co';
/**
* AuthProvider component - wrap your app with this
*/
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [loading, setLoading] = useState(true);
const [showAuthModal, setShowAuthModal] = useState(false);
const [authModalMode, setAuthModalMode] = useState('login'); // 'login' or 'signup'
const [pendingAction, setPendingAction] = useState(null); // Action to perform after login
// Load auth state from localStorage on mount
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const { user: storedUser, token: storedToken } = JSON.parse(stored);
setUser(storedUser);
setToken(storedToken);
} catch (e) {
console.error('Failed to parse stored auth:', e);
localStorage.removeItem(STORAGE_KEY);
}
}
setLoading(false);
}, []);
// Save auth state to localStorage when it changes
useEffect(() => {
if (user && token) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ user, token }));
} else {
localStorage.removeItem(STORAGE_KEY);
}
}, [user, token]);
/**
* Make authenticated API request
*/
const authFetch = useCallback(async (endpoint, options = {}) => {
const url = `${API_BASE_URL}${endpoint}`;
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(url, { ...options, headers });
// Handle 401 - token expired
if (response.status === 401) {
setUser(null);
setToken(null);
throw new Error('Session expired. Please log in again.');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Request failed' }));
throw new Error(error.error || `HTTP ${response.status}`);
}
return response.json();
}, [token]);
/**
* Register a new user
*/
const register = useCallback(async ({ firstName, lastName, email, password, phone, city, state }) => {
const response = await fetch(`${API_BASE_URL}/api/consumer/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
firstName,
lastName,
email,
password,
phone,
city,
state,
domain: DOMAIN,
notificationPreference: phone ? 'both' : 'email',
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registration failed');
}
setUser(data.user);
setToken(data.token);
setShowAuthModal(false);
// Execute pending action if any
if (pendingAction) {
setTimeout(() => {
pendingAction();
setPendingAction(null);
}, 100);
}
return data;
}, [pendingAction]);
/**
* Login user
*/
const login = useCallback(async (email, password) => {
const response = await fetch(`${API_BASE_URL}/api/consumer/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
password,
domain: DOMAIN,
}),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
setUser(data.user);
setToken(data.token);
setShowAuthModal(false);
// Execute pending action if any
if (pendingAction) {
setTimeout(() => {
pendingAction();
setPendingAction(null);
}, 100);
}
return data;
}, [pendingAction]);
/**
* Logout user
*/
const logout = useCallback(() => {
setUser(null);
setToken(null);
localStorage.removeItem(STORAGE_KEY);
}, []);
/**
* Update user profile
*/
const updateProfile = useCallback(async (updates) => {
const data = await authFetch('/api/consumer/auth/me', {
method: 'PUT',
body: JSON.stringify(updates),
});
// Refresh user data
const meData = await authFetch('/api/consumer/auth/me');
setUser(meData.user);
return data;
}, [authFetch]);
/**
* Require auth - shows modal if not logged in
* Returns true if authenticated, false if modal was shown
*
* @param {Function} action - Optional action to perform after successful auth
*/
const requireAuth = useCallback((action = null) => {
if (user && token) {
return true;
}
setPendingAction(() => action);
setAuthModalMode('login');
setShowAuthModal(true);
return false;
}, [user, token]);
/**
* Open auth modal in specific mode
*/
const openAuthModal = useCallback((mode = 'login') => {
setAuthModalMode(mode);
setShowAuthModal(true);
}, []);
/**
* Close auth modal
*/
const closeAuthModal = useCallback(() => {
setShowAuthModal(false);
setPendingAction(null);
}, []);
const value = {
// State
user,
token,
loading,
isAuthenticated: !!user && !!token,
showAuthModal,
authModalMode,
// Auth methods
register,
login,
logout,
updateProfile,
authFetch,
// Modal control
requireAuth,
openAuthModal,
closeAuthModal,
setAuthModalMode,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* Hook to use auth context
*/
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
export default AuthContext;

View File

@@ -0,0 +1,314 @@
import { useState, useEffect, useCallback } from 'react';
// Default location: Phoenix, AZ (fallback if all else fails)
const DEFAULT_LOCATION = {
lat: 33.4484,
lng: -112.0740,
city: 'Phoenix',
state: 'AZ'
};
const LOCATION_STORAGE_KEY = 'findagram_location';
const SESSION_ID_KEY = 'findagram_session_id';
const API_BASE_URL = process.env.REACT_APP_API_URL || '';
/**
* Get or create session ID
*/
function getSessionId() {
let sessionId = sessionStorage.getItem(SESSION_ID_KEY);
if (!sessionId) {
sessionId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
sessionStorage.setItem(SESSION_ID_KEY, sessionId);
}
return sessionId;
}
/**
* Get cached location from sessionStorage
*/
function getCachedLocation() {
try {
const cached = sessionStorage.getItem(LOCATION_STORAGE_KEY);
if (cached) {
return JSON.parse(cached);
}
} catch (err) {
console.error('Error reading cached location:', err);
}
return null;
}
/**
* Save location to sessionStorage
*/
function cacheLocation(location) {
try {
sessionStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(location));
} catch (err) {
console.error('Error caching location:', err);
}
}
/**
* Track visitor and get location from our backend API
* This logs the visit for analytics and returns location from IP
*/
async function trackVisitorAndGetLocation() {
try {
const response = await fetch(`${API_BASE_URL}/api/v1/visitor/track`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
domain: 'findagram.co',
page_path: window.location.pathname,
session_id: getSessionId(),
referrer: document.referrer || null,
}),
});
const data = await response.json();
if (data.success && data.location) {
return {
lat: data.location.lat,
lng: data.location.lng,
city: data.location.city,
state: data.location.state,
stateCode: data.location.stateCode,
source: 'api'
};
}
} catch (err) {
console.error('Visitor tracking error:', err);
}
return null;
}
/**
* Custom hook for getting user's geolocation
*
* @param {Object} options
* @param {boolean} options.autoRequest - Whether to request location automatically on mount
* @param {boolean} options.useIPFallback - Whether to use IP geolocation as fallback (default: true)
* @param {Object} options.defaultLocation - Default location if all methods fail
* @returns {Object} { location, loading, error, requestLocation, hasPermission }
*/
export function useGeolocation(options = {}) {
const {
autoRequest = false,
useIPFallback = true,
defaultLocation = DEFAULT_LOCATION
} = options;
const [location, setLocation] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasPermission, setHasPermission] = useState(null);
const [locationSource, setLocationSource] = useState(null); // 'gps', 'ip', or 'default'
// Try IP geolocation first (no permission needed)
const getIPLocation = useCallback(async () => {
if (!useIPFallback) return null;
const ipLoc = await getLocationFromIP();
if (ipLoc) {
setLocation(ipLoc);
setLocationSource('ip');
return ipLoc;
}
return null;
}, [useIPFallback]);
// Request precise GPS location (requires permission)
const requestLocation = useCallback(async () => {
setLoading(true);
setError(null);
// First try browser geolocation
if (navigator.geolocation) {
return new Promise(async (resolve) => {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
const loc = { lat: latitude, lng: longitude, source: 'gps' };
setLocation(loc);
setLocationSource('gps');
setHasPermission(true);
setLoading(false);
resolve(loc);
},
async (err) => {
console.error('Geolocation error:', err);
if (err.code === err.PERMISSION_DENIED) {
setHasPermission(false);
}
// Fall back to IP geolocation
if (useIPFallback) {
const ipLoc = await getLocationFromIP();
if (ipLoc) {
setLocation(ipLoc);
setLocationSource('ip');
setLoading(false);
resolve(ipLoc);
return;
}
}
// Last resort: default location
setError('Unable to determine location');
setLocation(defaultLocation);
setLocationSource('default');
setLoading(false);
resolve(defaultLocation);
},
{
enableHighAccuracy: false,
timeout: 5000,
maximumAge: 600000 // Cache for 10 minutes
}
);
});
}
// No browser geolocation, try IP
if (useIPFallback) {
const ipLoc = await getLocationFromIP();
if (ipLoc) {
setLocation(ipLoc);
setLocationSource('ip');
setLoading(false);
return ipLoc;
}
}
// Fallback to default
setLocation(defaultLocation);
setLocationSource('default');
setLoading(false);
return defaultLocation;
}, [defaultLocation, useIPFallback]);
// Auto-request location on mount if enabled
useEffect(() => {
if (autoRequest) {
const init = async () => {
// Check for cached location first
const cached = getCachedLocation();
if (cached) {
setLocation(cached);
setLocationSource(cached.source || 'api');
setLoading(false);
return;
}
setLoading(true);
// Track visitor and get location from our backend API
const apiLoc = await trackVisitorAndGetLocation();
if (apiLoc) {
setLocation(apiLoc);
setLocationSource('api');
cacheLocation(apiLoc); // Save for session
} else {
// Fallback to default
setLocation(defaultLocation);
setLocationSource('default');
}
setLoading(false);
};
init();
}
}, [autoRequest, defaultLocation]);
return {
location,
loading,
error,
requestLocation,
hasPermission,
locationSource,
isDefault: locationSource === 'default',
isFromIP: locationSource === 'ip',
isFromGPS: locationSource === 'gps'
};
}
/**
* Calculate distance between two points using Haversine formula
*
* @param {number} lat1 - First point latitude
* @param {number} lng1 - First point longitude
* @param {number} lat2 - Second point latitude
* @param {number} lng2 - Second point longitude
* @returns {number} Distance in miles
*/
export function calculateDistance(lat1, lng1, lat2, lng2) {
const R = 3959; // Earth's radius in miles
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function toRad(deg) {
return deg * (Math.PI / 180);
}
/**
* Sort items by distance from a location
*
* @param {Array} items - Array of items with location data
* @param {Object} userLocation - User's location { lat, lng }
* @param {Function} getItemLocation - Function to extract lat/lng from item
* @returns {Array} Items sorted by distance with distance property added
*/
export function sortByDistance(items, userLocation, getItemLocation = (item) => item.location) {
if (!userLocation || !items?.length) return items;
return items
.map(item => {
const itemLoc = getItemLocation(item);
if (!itemLoc?.latitude || !itemLoc?.longitude) {
return { ...item, distance: null };
}
const distance = calculateDistance(
userLocation.lat,
userLocation.lng,
itemLoc.latitude,
itemLoc.longitude
);
return { ...item, distance: Math.round(distance * 10) / 10 };
})
.sort((a, b) => {
if (a.distance === null) return 1;
if (b.distance === null) return -1;
return a.distance - b.distance;
});
}
/**
* Filter items within a radius from user location
*
* @param {Array} items - Array of items with location data
* @param {Object} userLocation - User's location { lat, lng }
* @param {number} radiusMiles - Max distance in miles
* @param {Function} getItemLocation - Function to extract lat/lng from item
* @returns {Array} Items within radius, sorted by distance
*/
export function filterByRadius(items, userLocation, radiusMiles = 50, getItemLocation = (item) => item.location) {
const sorted = sortByDistance(items, userLocation, getItemLocation);
return sorted.filter(item => item.distance !== null && item.distance <= radiusMiles);
}
export default useGeolocation;

View File

@@ -0,0 +1,363 @@
/**
* localStorage helpers for user data persistence
*
* Manages favorites, price alerts, and saved searches without requiring authentication.
* All data is stored locally in the browser.
*/
const STORAGE_KEYS = {
FAVORITES: 'findagram_favorites',
ALERTS: 'findagram_alerts',
SAVED_SEARCHES: 'findagram_saved_searches',
};
// ============================================================
// FAVORITES
// ============================================================
/**
* Get all favorite product IDs
* @returns {number[]} Array of product IDs
*/
export function getFavorites() {
try {
const data = localStorage.getItem(STORAGE_KEYS.FAVORITES);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('Error reading favorites:', e);
return [];
}
}
/**
* Check if a product is favorited
* @param {number} productId
* @returns {boolean}
*/
export function isFavorite(productId) {
const favorites = getFavorites();
return favorites.includes(productId);
}
/**
* Add a product to favorites
* @param {number} productId
*/
export function addFavorite(productId) {
const favorites = getFavorites();
if (!favorites.includes(productId)) {
favorites.push(productId);
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites));
}
}
/**
* Remove a product from favorites
* @param {number} productId
*/
export function removeFavorite(productId) {
const favorites = getFavorites();
const updated = favorites.filter(id => id !== productId);
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(updated));
}
/**
* Toggle a product's favorite status
* @param {number} productId
* @returns {boolean} New favorite status
*/
export function toggleFavorite(productId) {
if (isFavorite(productId)) {
removeFavorite(productId);
return false;
} else {
addFavorite(productId);
return true;
}
}
/**
* Clear all favorites
*/
export function clearFavorites() {
localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify([]));
}
// ============================================================
// PRICE ALERTS
// ============================================================
/**
* @typedef {Object} PriceAlert
* @property {string} id - Unique alert ID
* @property {number} productId - Product ID to track
* @property {string} productName - Product name (for display when offline)
* @property {string} productImage - Product image URL
* @property {string} brandName - Brand name
* @property {number} targetPrice - Target price to alert at
* @property {number} originalPrice - Price when alert was created
* @property {boolean} active - Whether alert is active
* @property {string} createdAt - ISO date string
*/
/**
* Get all price alerts
* @returns {PriceAlert[]}
*/
export function getAlerts() {
try {
const data = localStorage.getItem(STORAGE_KEYS.ALERTS);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('Error reading alerts:', e);
return [];
}
}
/**
* Get alert for a specific product
* @param {number} productId
* @returns {PriceAlert|null}
*/
export function getAlertForProduct(productId) {
const alerts = getAlerts();
return alerts.find(a => a.productId === productId) || null;
}
/**
* Create a new price alert
* @param {Object} params
* @param {number} params.productId
* @param {string} params.productName
* @param {string} params.productImage
* @param {string} params.brandName
* @param {number} params.targetPrice
* @param {number} params.originalPrice
* @returns {PriceAlert}
*/
export function createAlert({ productId, productName, productImage, brandName, targetPrice, originalPrice }) {
const alerts = getAlerts();
// Check if alert already exists for this product
const existingIndex = alerts.findIndex(a => a.productId === productId);
const alert = {
id: existingIndex >= 0 ? alerts[existingIndex].id : `alert_${Date.now()}`,
productId,
productName,
productImage,
brandName,
targetPrice,
originalPrice,
active: true,
createdAt: existingIndex >= 0 ? alerts[existingIndex].createdAt : new Date().toISOString(),
};
if (existingIndex >= 0) {
alerts[existingIndex] = alert;
} else {
alerts.push(alert);
}
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
return alert;
}
/**
* Update an existing alert
* @param {string} alertId
* @param {Partial<PriceAlert>} updates
*/
export function updateAlert(alertId, updates) {
const alerts = getAlerts();
const index = alerts.findIndex(a => a.id === alertId);
if (index >= 0) {
alerts[index] = { ...alerts[index], ...updates };
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
}
}
/**
* Toggle alert active status
* @param {string} alertId
* @returns {boolean} New active status
*/
export function toggleAlertActive(alertId) {
const alerts = getAlerts();
const alert = alerts.find(a => a.id === alertId);
if (alert) {
alert.active = !alert.active;
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(alerts));
return alert.active;
}
return false;
}
/**
* Delete an alert
* @param {string} alertId
*/
export function deleteAlert(alertId) {
const alerts = getAlerts();
const updated = alerts.filter(a => a.id !== alertId);
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify(updated));
}
/**
* Clear all alerts
*/
export function clearAlerts() {
localStorage.setItem(STORAGE_KEYS.ALERTS, JSON.stringify([]));
}
// ============================================================
// SAVED SEARCHES
// ============================================================
/**
* @typedef {Object} SavedSearch
* @property {string} id - Unique search ID
* @property {string} name - User-defined name for the search
* @property {Object} filters - Search filter parameters
* @property {string} [filters.search] - Search term
* @property {string} [filters.type] - Category type
* @property {string} [filters.brandName] - Brand filter
* @property {string} [filters.strainType] - Strain type filter
* @property {number} [filters.priceMax] - Max price filter
* @property {number} [filters.thcMin] - Min THC filter
* @property {string} createdAt - ISO date string
*/
/**
* Get all saved searches
* @returns {SavedSearch[]}
*/
export function getSavedSearches() {
try {
const data = localStorage.getItem(STORAGE_KEYS.SAVED_SEARCHES);
return data ? JSON.parse(data) : [];
} catch (e) {
console.error('Error reading saved searches:', e);
return [];
}
}
/**
* Create a new saved search
* @param {Object} params
* @param {string} params.name - Display name for the search
* @param {Object} params.filters - Search filters
* @returns {SavedSearch}
*/
export function createSavedSearch({ name, filters }) {
const searches = getSavedSearches();
const search = {
id: `search_${Date.now()}`,
name,
filters,
createdAt: new Date().toISOString(),
};
searches.push(search);
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(searches));
return search;
}
/**
* Update a saved search
* @param {string} searchId
* @param {Partial<SavedSearch>} updates
*/
export function updateSavedSearch(searchId, updates) {
const searches = getSavedSearches();
const index = searches.findIndex(s => s.id === searchId);
if (index >= 0) {
searches[index] = { ...searches[index], ...updates };
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(searches));
}
}
/**
* Delete a saved search
* @param {string} searchId
*/
export function deleteSavedSearch(searchId) {
const searches = getSavedSearches();
const updated = searches.filter(s => s.id !== searchId);
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify(updated));
}
/**
* Clear all saved searches
*/
export function clearSavedSearches() {
localStorage.setItem(STORAGE_KEYS.SAVED_SEARCHES, JSON.stringify([]));
}
// ============================================================
// UTILITY FUNCTIONS
// ============================================================
/**
* Build a URL with search params from filters
* @param {Object} filters
* @returns {string}
*/
export function buildSearchUrl(filters) {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
params.set(key, value);
}
});
return `/products?${params.toString()}`;
}
/**
* Generate a name for a search based on its filters
* @param {Object} filters
* @returns {string}
*/
export function generateSearchName(filters) {
const parts = [];
if (filters.search) parts.push(`"${filters.search}"`);
if (filters.type) parts.push(filters.type);
if (filters.brandName) parts.push(filters.brandName);
if (filters.strainType) parts.push(filters.strainType);
if (filters.priceMax) parts.push(`Under $${filters.priceMax}`);
if (filters.thcMin) parts.push(`${filters.thcMin}%+ THC`);
return parts.length > 0 ? parts.join(' - ') : 'All Products';
}
// Default export
const storage = {
// Favorites
getFavorites,
isFavorite,
addFavorite,
removeFavorite,
toggleFavorite,
clearFavorites,
// Alerts
getAlerts,
getAlertForProduct,
createAlert,
updateAlert,
toggleAlertActive,
deleteAlert,
clearAlerts,
// Saved Searches
getSavedSearches,
createSavedSearch,
updateSavedSearch,
deleteSavedSearch,
clearSavedSearches,
// Utilities
buildSearchUrl,
generateSearchName,
};
export default storage;

View File

@@ -1,28 +1,90 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import { Card, CardContent } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { mockAlerts, mockProducts } from '../../mockData';
import { Bell, Trash2, Pause, Play, TrendingDown } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { getAlerts, toggleAlert, deleteAlert } from '../../api/consumer';
import { Bell, Trash2, Pause, Play, TrendingDown, Loader2 } from 'lucide-react';
const Alerts = () => {
const [alerts, setAlerts] = useState(mockAlerts);
const { isAuthenticated, authFetch, requireAuth } = useAuth();
const navigate = useNavigate();
const toggleAlert = (alertId) => {
setAlerts((prev) =>
prev.map((alert) =>
alert.id === alertId ? { ...alert, active: !alert.active } : alert
)
const [alerts, setAlerts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [togglingId, setTogglingId] = useState(null);
const [deletingId, setDeletingId] = useState(null);
// Redirect to home if not authenticated
useEffect(() => {
if (!isAuthenticated) {
requireAuth(() => navigate('/dashboard/alerts'));
}
}, [isAuthenticated, requireAuth, navigate]);
// Fetch alerts
useEffect(() => {
if (!isAuthenticated) return;
const fetchAlerts = async () => {
setLoading(true);
setError(null);
try {
const data = await getAlerts(authFetch);
setAlerts(data.alerts || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchAlerts();
}, [isAuthenticated, authFetch]);
const handleToggleAlert = async (alertId) => {
setTogglingId(alertId);
try {
const result = await toggleAlert(authFetch, alertId);
setAlerts(prev =>
prev.map(a => a.id === alertId ? { ...a, isActive: result.isActive } : a)
);
} catch (err) {
setError(err.message);
} finally {
setTogglingId(null);
}
};
const handleDeleteAlert = async (alertId) => {
setDeletingId(alertId);
try {
await deleteAlert(authFetch, alertId);
setAlerts(prev => prev.filter(a => a.id !== alertId));
} catch (err) {
setError(err.message);
} finally {
setDeletingId(null);
}
};
if (!isAuthenticated) {
return null;
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
};
}
const deleteAlert = (alertId) => {
setAlerts((prev) => prev.filter((alert) => alert.id !== alertId));
};
const activeAlerts = alerts.filter((a) => a.active);
const pausedAlerts = alerts.filter((a) => !a.active);
const activeAlerts = alerts.filter(a => a.isActive);
const pausedAlerts = alerts.filter(a => !a.isActive);
return (
<div className="min-h-screen bg-gray-50">
@@ -50,6 +112,12 @@ const Alerts = () => {
</section>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
{error}
</div>
)}
{alerts.length > 0 ? (
<div className="space-y-8">
{/* Active Alerts */}
@@ -61,40 +129,46 @@ const Alerts = () => {
</h2>
<div className="space-y-4">
{activeAlerts.map((alert) => {
const product = mockProducts.find((p) => p.id === alert.productId);
const priceDiff = product ? product.price - alert.targetPrice : 0;
const isTriggered = priceDiff <= 0;
const isTriggered = alert.isTriggered;
return (
<Card key={alert.id} className={isTriggered ? 'border-green-500 bg-green-50' : ''}>
<CardContent className="p-4">
<div className="flex items-center gap-4">
<Link to={`/products/${product?.id}`}>
<Link to={`/products/${alert.productId}`}>
<img
src={product?.image || '/placeholder-product.jpg'}
alt={product?.name}
src={alert.productImage || '/placeholder-product.jpg'}
alt={alert.productName}
className="w-16 h-16 rounded-lg object-cover"
/>
</Link>
<div className="flex-1 min-w-0">
<Link
to={`/products/${product?.id}`}
to={`/products/${alert.productId}`}
className="font-medium text-gray-900 hover:text-primary truncate block"
>
{product?.name}
{alert.productName}
</Link>
<p className="text-sm text-gray-500">{product?.brand}</p>
<p className="text-sm text-gray-500">{alert.productBrand}</p>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm">
Current: <span className="font-medium">${product?.price.toFixed(2)}</span>
Current:{' '}
<span className="font-medium">
{alert.currentPrice
? `$${parseFloat(alert.currentPrice).toFixed(2)}`
: 'N/A'}
</span>
</span>
<span className="text-sm">
Target: <span className="font-medium text-primary">${alert.targetPrice.toFixed(2)}</span>
Target:{' '}
<span className="font-medium text-primary">
${parseFloat(alert.targetPrice).toFixed(2)}
</span>
</span>
</div>
</div>
{isTriggered && (
<Badge variant="success" className="flex items-center gap-1">
<Badge className="bg-green-500 text-white flex items-center gap-1">
<TrendingDown className="h-3 w-3" />
Price Dropped!
</Badge>
@@ -103,19 +177,29 @@ const Alerts = () => {
<Button
variant="ghost"
size="icon"
onClick={() => toggleAlert(alert.id)}
onClick={() => handleToggleAlert(alert.id)}
disabled={togglingId === alert.id}
title="Pause alert"
>
<Pause className="h-4 w-4" />
{togglingId === alert.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteAlert(alert.id)}
onClick={() => handleDeleteAlert(alert.id)}
disabled={deletingId === alert.id}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title="Delete alert"
>
<Trash2 className="h-4 w-4" />
{deletingId === alert.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
@@ -135,52 +219,61 @@ const Alerts = () => {
Paused Alerts ({pausedAlerts.length})
</h2>
<div className="space-y-4">
{pausedAlerts.map((alert) => {
const product = mockProducts.find((p) => p.id === alert.productId);
return (
<Card key={alert.id} className="opacity-75">
<CardContent className="p-4">
<div className="flex items-center gap-4">
<img
src={product?.image || '/placeholder-product.jpg'}
alt={product?.name}
className="w-16 h-16 rounded-lg object-cover grayscale"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{product?.name}
</p>
<p className="text-sm text-gray-500">{product?.brand}</p>
<span className="text-sm">
Target: <span className="font-medium">${alert.targetPrice.toFixed(2)}</span>
{pausedAlerts.map((alert) => (
<Card key={alert.id} className="opacity-75">
<CardContent className="p-4">
<div className="flex items-center gap-4">
<img
src={alert.productImage || '/placeholder-product.jpg'}
alt={alert.productName}
className="w-16 h-16 rounded-lg object-cover grayscale"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{alert.productName}
</p>
<p className="text-sm text-gray-500">{alert.productBrand}</p>
<span className="text-sm">
Target:{' '}
<span className="font-medium">
${parseFloat(alert.targetPrice).toFixed(2)}
</span>
</div>
<Badge variant="secondary">Paused</Badge>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => toggleAlert(alert.id)}
title="Resume alert"
>
<Play className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => deleteAlert(alert.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title="Delete alert"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</span>
</div>
</CardContent>
</Card>
);
})}
<Badge variant="secondary">Paused</Badge>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleAlert(alert.id)}
disabled={togglingId === alert.id}
title="Resume alert"
>
{togglingId === alert.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteAlert(alert.id)}
disabled={deletingId === alert.id}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title="Delete alert"
>
{deletingId === alert.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}

View File

@@ -27,7 +27,7 @@ const Brands = () => {
}, []);
const filteredBrands = brands.filter((brand) =>
brand.name.toLowerCase().includes(searchQuery.toLowerCase())
brand.name && brand.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Group brands alphabetically

View File

@@ -1,14 +1,10 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button';
import {
mockFavorites,
mockAlerts,
mockSavedSearches,
mockProducts,
} from '../../mockData';
import { useAuth } from '../../context/AuthContext';
import { getFavorites, getAlerts, getAlertStats, getSavedSearches } from '../../api/consumer';
import {
Heart,
Bell,
@@ -17,23 +13,80 @@ import {
ChevronRight,
TrendingDown,
Search,
Loader2,
} from 'lucide-react';
const Dashboard = () => {
// Get favorite products
const favoriteProducts = mockProducts.filter((p) =>
mockFavorites.includes(p.id)
);
const { isAuthenticated, user, authFetch, requireAuth } = useAuth();
const navigate = useNavigate();
// Get active alerts
const activeAlerts = mockAlerts.filter((a) => a.active);
const [favorites, setFavorites] = useState([]);
const [alerts, setAlerts] = useState([]);
const [alertStats, setAlertStats] = useState({ active: 0, triggeredThisWeek: 0 });
const [savedSearches, setSavedSearches] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Redirect to home if not authenticated
useEffect(() => {
if (!isAuthenticated) {
requireAuth(() => navigate('/dashboard'));
}
}, [isAuthenticated, requireAuth, navigate]);
// Fetch all dashboard data
useEffect(() => {
if (!isAuthenticated) return;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [favData, alertData, statsData, searchData] = await Promise.all([
getFavorites(authFetch).catch(() => ({ favorites: [] })),
getAlerts(authFetch).catch(() => ({ alerts: [] })),
getAlertStats(authFetch).catch(() => ({ active: 0, triggeredThisWeek: 0 })),
getSavedSearches(authFetch).catch(() => ({ savedSearches: [] })),
]);
setFavorites(favData.favorites || []);
setAlerts(alertData.alerts || []);
setAlertStats(statsData);
setSavedSearches(searchData.savedSearches || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, [isAuthenticated, authFetch]);
if (!isAuthenticated) {
return null; // Will redirect via useEffect
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
const activeAlerts = alerts.filter(a => a.isActive);
const triggeredAlerts = alerts.filter(a => a.isTriggered);
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<section className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<h1 className="text-3xl font-bold text-gray-900">
Welcome back, {user?.firstName || 'there'}!
</h1>
<p className="text-gray-600 mt-2">
Manage your favorites, alerts, and saved searches
</p>
@@ -41,6 +94,12 @@ const Dashboard = () => {
</section>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
{error}
</div>
)}
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card>
@@ -48,7 +107,7 @@ const Dashboard = () => {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Favorites</p>
<p className="text-2xl font-bold">{mockFavorites.length}</p>
<p className="text-2xl font-bold">{favorites.length}</p>
</div>
<Heart className="h-8 w-8 text-red-500" />
</div>
@@ -60,7 +119,7 @@ const Dashboard = () => {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Active Alerts</p>
<p className="text-2xl font-bold">{activeAlerts.length}</p>
<p className="text-2xl font-bold">{alertStats.active}</p>
</div>
<Bell className="h-8 w-8 text-primary" />
</div>
@@ -72,7 +131,7 @@ const Dashboard = () => {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Saved Searches</p>
<p className="text-2xl font-bold">{mockSavedSearches.length}</p>
<p className="text-2xl font-bold">{savedSearches.length}</p>
</div>
<Bookmark className="h-8 w-8 text-indigo-500" />
</div>
@@ -84,7 +143,7 @@ const Dashboard = () => {
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500">Price Drops</p>
<p className="text-2xl font-bold">3</p>
<p className="text-2xl font-bold">{triggeredAlerts.length}</p>
</div>
<TrendingDown className="h-8 w-8 text-green-500" />
</div>
@@ -108,28 +167,39 @@ const Dashboard = () => {
</Link>
</CardHeader>
<CardContent>
{favoriteProducts.length > 0 ? (
{favorites.length > 0 ? (
<div className="space-y-4">
{favoriteProducts.slice(0, 3).map((product) => (
{favorites.slice(0, 3).map((fav) => (
<Link
key={product.id}
to={`/products/${product.id}`}
key={fav.id}
to={`/products/${fav.productId}`}
className="flex items-center gap-4 p-3 rounded-lg hover:bg-gray-50 transition-colors"
>
<img
src={product.image || '/placeholder-product.jpg'}
alt={product.name}
src={fav.imageUrl || '/placeholder-product.jpg'}
alt={fav.savedName}
className="w-12 h-12 rounded-lg object-cover"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{product.name}
{fav.currentName || fav.savedName}
</p>
<p className="text-sm text-gray-500">{product.brand}</p>
<p className="text-sm text-gray-500">{fav.currentBrand || fav.savedBrand}</p>
</div>
<div className="text-right">
{fav.currentPrice ? (
<p className="font-bold text-primary">
${parseFloat(fav.currentPrice).toFixed(2)}
</p>
) : (
<p className="text-sm text-gray-400">No price</p>
)}
{fav.priceDrop && (
<Badge variant="success" className="text-xs">
Price dropped!
</Badge>
)}
</div>
<p className="font-bold text-primary">
${product.price.toFixed(2)}
</p>
</Link>
))}
</div>
@@ -160,34 +230,40 @@ const Dashboard = () => {
</Link>
</CardHeader>
<CardContent>
{mockAlerts.length > 0 ? (
{alerts.length > 0 ? (
<div className="space-y-4">
{mockAlerts.slice(0, 3).map((alert) => {
const product = mockProducts.find((p) => p.id === alert.productId);
return (
<div
key={alert.id}
className="flex items-center gap-4 p-3 rounded-lg bg-gray-50"
>
<img
src={product?.image || '/placeholder-product.jpg'}
alt={product?.name}
className="w-12 h-12 rounded-lg object-cover"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{product?.name}
</p>
<p className="text-sm text-gray-500">
Alert at ${alert.targetPrice.toFixed(2)}
</p>
</div>
<Badge variant={alert.active ? 'default' : 'secondary'}>
{alert.active ? 'Active' : 'Paused'}
</Badge>
{alerts.slice(0, 3).map((alert) => (
<div
key={alert.id}
className={`flex items-center gap-4 p-3 rounded-lg ${
alert.isTriggered ? 'bg-green-50' : 'bg-gray-50'
}`}
>
<img
src={alert.productImage || '/placeholder-product.jpg'}
alt={alert.productName}
className="w-12 h-12 rounded-lg object-cover"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{alert.productName}
</p>
<p className="text-sm text-gray-500">
Target: ${parseFloat(alert.targetPrice).toFixed(2)}
</p>
</div>
);
})}
{alert.isTriggered ? (
<Badge variant="success" className="flex items-center gap-1">
<TrendingDown className="h-3 w-3" />
Triggered!
</Badge>
) : (
<Badge variant={alert.isActive ? 'default' : 'secondary'}>
{alert.isActive ? 'Active' : 'Paused'}
</Badge>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8">
@@ -216,22 +292,41 @@ const Dashboard = () => {
</Link>
</CardHeader>
<CardContent>
{mockSavedSearches.length > 0 ? (
{savedSearches.length > 0 ? (
<div className="space-y-3">
{mockSavedSearches.slice(0, 4).map((search) => (
<Link
key={search.id}
to={`/products?${new URLSearchParams(search.filters).toString()}`}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
>
<Search className="h-5 w-5 text-gray-400" />
<div className="flex-1">
<p className="font-medium text-gray-900">{search.name}</p>
<p className="text-sm text-gray-500">{search.resultCount} results</p>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
</Link>
))}
{savedSearches.slice(0, 4).map((search) => {
// Build search URL
const params = new URLSearchParams();
if (search.query) params.set('search', search.query);
if (search.category) params.set('type', search.category);
if (search.brand) params.set('brandName', search.brand);
const searchUrl = `/products?${params.toString()}`;
return (
<Link
key={search.id}
to={searchUrl}
className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
>
<Search className="h-5 w-5 text-gray-400" />
<div className="flex-1">
<p className="font-medium text-gray-900">{search.name}</p>
<div className="flex flex-wrap gap-1 mt-1">
{search.category && (
<Badge variant="secondary" className="text-xs">{search.category}</Badge>
)}
{search.brand && (
<Badge variant="outline" className="text-xs">{search.brand}</Badge>
)}
{search.strainType && (
<Badge variant="outline" className="text-xs">{search.strainType}</Badge>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 text-gray-400" />
</Link>
);
})}
</div>
) : (
<div className="text-center py-8">

View File

@@ -3,35 +3,63 @@ import { Link } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import ProductCard from '../../components/findagram/ProductCard';
import { getDeals, getProducts, mapProductForUI } from '../../api/client';
import { Tag, TrendingDown, Clock, Flame, Loader2 } from 'lucide-react';
import { getSpecials, getDispensaries, mapProductForUI, mapDispensaryForUI } from '../../api/client';
import { useGeolocation, sortByDistance } from '../../hooks/useGeolocation';
import { Tag, TrendingDown, Clock, Flame, Loader2, MapPin, Navigation } from 'lucide-react';
const Deals = () => {
const [favorites, setFavorites] = useState([]);
const [filter, setFilter] = useState('all');
// API state
const [allProducts, setAllProducts] = useState([]);
const [dealsProducts, setDealsProducts] = useState([]);
const [loading, setLoading] = useState(true);
// Geolocation
const { location, loading: locationLoading, requestLocation } = useGeolocation({ autoRequest: true });
// Fetch data on mount
// API state
const [specials, setSpecials] = useState([]);
const [nearbyDispensaryIds, setNearbyDispensaryIds] = useState([]);
const [loading, setLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// Fetch nearby dispensaries when location is available
useEffect(() => {
const fetchNearbyDispensaries = async () => {
if (!location) return;
try {
const res = await getDispensaries({ limit: 100, hasProducts: true });
const dispensaries = (res.dispensaries || []).map(mapDispensaryForUI);
// Sort by distance and get IDs of nearest 20
const sorted = sortByDistance(dispensaries, location, (d) => ({
latitude: d.latitude,
longitude: d.longitude
}));
const nearbyIds = sorted.slice(0, 20).map(d => d.id);
setNearbyDispensaryIds(nearbyIds);
} catch (err) {
console.error('Error fetching nearby dispensaries:', err);
}
};
fetchNearbyDispensaries();
}, [location]);
// Fetch specials on mount
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const [dealsRes, productsRes] = await Promise.all([
getDeals({ limit: 50 }),
getProducts({ limit: 50 }),
]);
const res = await getSpecials({ limit: 100 });
// Set deals products (products with sale_price)
setDealsProducts((dealsRes.products || []).map(mapProductForUI));
// Set all products for fallback display
setAllProducts((productsRes.products || []).map(mapProductForUI));
const products = (res.products || []).map(mapProductForUI);
setSpecials(products);
setTotalCount(res.pagination?.total || products.length);
setHasMore(res.pagination?.has_more || false);
} catch (err) {
console.error('Error fetching deals data:', err);
console.error('Error fetching specials:', err);
} finally {
setLoading(false);
}
@@ -39,6 +67,22 @@ const Deals = () => {
fetchData();
}, []);
const loadMore = async () => {
if (loadingMore || !hasMore) return;
try {
setLoadingMore(true);
const res = await getSpecials({ limit: 50, offset: specials.length });
const newProducts = (res.products || []).map(mapProductForUI);
setSpecials(prev => [...prev, ...newProducts]);
setHasMore(res.pagination?.has_more || false);
} catch (err) {
console.error('Error loading more specials:', err);
} finally {
setLoadingMore(false);
}
};
const toggleFavorite = (productId) => {
setFavorites((prev) =>
prev.includes(productId)
@@ -47,31 +91,39 @@ const Deals = () => {
);
};
// Use dealsProducts if available, otherwise fall back to allProducts
const displayProducts = dealsProducts.length > 0 ? dealsProducts : allProducts;
// Filter specials to only show products from nearby dispensaries
const nearbySpecials = nearbyDispensaryIds.length > 0
? specials.filter(p => nearbyDispensaryIds.includes(p.dispensaryId))
: specials;
// Create some "deal categories" from available products
const hotDeals = displayProducts.slice(0, 4);
const todayOnly = displayProducts.slice(4, 8);
const weeklySpecials = displayProducts.slice(0, 8);
// Filter products by category for display sections
const flowerSpecials = nearbySpecials.filter(p =>
p.category?.toLowerCase().includes('flower')
).slice(0, 8);
const edibleSpecials = nearbySpecials.filter(p =>
p.category?.toLowerCase().includes('edible')
).slice(0, 8);
const concentrateSpecials = nearbySpecials.filter(p =>
p.category?.toLowerCase().includes('concentrate') || p.category?.toLowerCase().includes('vape')
).slice(0, 8);
const filterOptions = [
{ id: 'all', label: 'All Deals', icon: Tag },
{ id: 'hot', label: 'Hot Deals', icon: Flame },
{ id: 'today', label: 'Today Only', icon: Clock },
{ id: 'weekly', label: 'Weekly Specials', icon: TrendingDown },
{ id: 'all', label: 'All Specials', icon: Tag },
{ id: 'flower', label: 'Flower', icon: Flame },
{ id: 'edibles', label: 'Edibles', icon: Clock },
{ id: 'concentrates', label: 'Concentrates', icon: TrendingDown },
];
const getFilteredProducts = () => {
switch (filter) {
case 'hot':
return hotDeals;
case 'today':
return todayOnly;
case 'weekly':
return weeklySpecials;
case 'flower':
return flowerSpecials;
case 'edibles':
return edibleSpecials;
case 'concentrates':
return concentrateSpecials;
default:
return displayProducts;
return nearbySpecials;
}
};
@@ -89,6 +141,18 @@ const Deals = () => {
<p className="text-lg text-pink-100 max-w-2xl mx-auto">
Save big on top cannabis products. Prices updated daily from dispensaries near you.
</p>
{location && (
<div className="mt-4 inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2">
<MapPin className="h-4 w-4" />
<span className="text-sm">Showing deals near your location</span>
</div>
)}
{locationLoading && (
<div className="mt-4 inline-flex items-center gap-2 bg-white/20 rounded-full px-4 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Finding deals near you...</span>
</div>
)}
</div>
</div>
</section>
@@ -98,17 +162,25 @@ const Deals = () => {
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<p className="text-2xl font-bold text-pink-600">{loading ? '...' : dealsProducts.length > 0 ? `${dealsProducts.length}+` : `${allProducts.length}+`}</p>
<p className="text-sm text-gray-600">Products on Sale</p>
<p className="text-2xl font-bold text-pink-600">
{loading ? '...' : nearbySpecials.length > 0 ? nearbySpecials.length : '0'}
</p>
<p className="text-sm text-gray-600">
{location ? 'Specials Near You' : 'Products on Special'}
</p>
</div>
<div>
<p className="text-2xl font-bold text-pink-600">
{nearbyDispensaryIds.length > 0 ? nearbyDispensaryIds.length : '20+'}
</p>
<p className="text-sm text-gray-600">
{location ? 'Nearby Dispensaries' : 'Dispensaries'}
</p>
</div>
<div>
<p className="text-2xl font-bold text-pink-600">Up to 40%</p>
<p className="text-sm text-gray-600">Savings</p>
</div>
<div>
<p className="text-2xl font-bold text-pink-600">200+</p>
<p className="text-sm text-gray-600">Dispensaries</p>
</div>
<div>
<p className="text-2xl font-bold text-pink-600">Daily</p>
<p className="text-sm text-gray-600">Price Updates</p>
@@ -153,74 +225,28 @@ const Deals = () => {
</div>
)}
{/* Hot Deals Section */}
{(filter === 'all' || filter === 'hot') && (
<section className="mb-12">
<div className="flex items-center gap-2 mb-6">
<Flame className="h-6 w-6 text-orange-500" />
<h2 className="text-2xl font-bold text-gray-900">Hot Deals</h2>
<Badge variant="deal">Limited Time</Badge>
</div>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{(filter === 'hot' ? getFilteredProducts() : hotDeals).map((product) => (
<ProductCard
key={product.id}
product={product}
onFavorite={toggleFavorite}
isFavorite={favorites.includes(product.id)}
/>
))}
</div>
)}
</section>
)}
{/* Today Only Section */}
{(filter === 'all' || filter === 'today') && (
<section className="mb-12">
<div className="flex items-center gap-2 mb-6">
<Clock className="h-6 w-6 text-red-500" />
<h2 className="text-2xl font-bold text-gray-900">Today Only</h2>
<Badge className="bg-red-100 text-red-800">Ends at Midnight</Badge>
</div>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{(filter === 'today' ? getFilteredProducts() : todayOnly).map((product) => (
<ProductCard
key={product.id}
product={product}
onFavorite={toggleFavorite}
isFavorite={favorites.includes(product.id)}
/>
))}
</div>
)}
</section>
)}
{/* Weekly Specials Section */}
{(filter === 'all' || filter === 'weekly') && (
<section className="mb-12">
<div className="flex items-center gap-2 mb-6">
{/* Products Section */}
<section className="mb-12">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<TrendingDown className="h-6 w-6 text-green-500" />
<h2 className="text-2xl font-bold text-gray-900">Weekly Specials</h2>
<h2 className="text-2xl font-bold text-gray-900">
{filter === 'all' ? 'All Specials' :
filter === 'flower' ? 'Flower Specials' :
filter === 'edibles' ? 'Edible Specials' : 'Concentrate Specials'}
</h2>
<Badge variant="deal">{getFilteredProducts().length} products</Badge>
</div>
{loading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
</div>
{loading ? (
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : getFilteredProducts().length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{(filter === 'weekly' ? getFilteredProducts() : weeklySpecials).map((product) => (
{getFilteredProducts().map((product) => (
<ProductCard
key={product.id}
product={product}
@@ -229,9 +255,38 @@ const Deals = () => {
/>
))}
</div>
)}
</section>
)}
{/* Load More Button */}
{filter === 'all' && hasMore && (
<div className="text-center mt-8">
<Button
variant="outline"
size="lg"
onClick={loadMore}
disabled={loadingMore}
>
{loadingMore ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading...
</>
) : (
'Load More Specials'
)}
</Button>
</div>
)}
</>
) : (
<div className="text-center py-12 bg-white rounded-lg">
<Tag className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 mb-4">No specials found in this category.</p>
<Button variant="outline" onClick={() => setFilter('all')}>
View All Specials
</Button>
</div>
)}
</section>
{/* CTA Section */}
<section className="bg-white rounded-xl p-8 text-center">

View File

@@ -0,0 +1,654 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Card, CardContent } from '../../components/ui/card';
import ProductCard from '../../components/findagram/ProductCard';
import {
getDispensaryBySlug,
getDispensarySummary,
getDispensaryBrands,
getDispensaryCategories,
getStoreProducts,
mapDispensaryForUI,
mapProductForUI,
} from '../../api/client';
import {
Store,
ChevronRight,
MapPin,
Phone,
Globe,
Clock,
Truck,
ShoppingBag,
Car,
Loader2,
Package,
Tag,
Filter,
X,
Search,
ChevronDown,
} from 'lucide-react';
const DispensaryDetail = () => {
const { slug } = useParams();
const [dispensary, setDispensary] = useState(null);
const [summary, setSummary] = useState(null);
const [brands, setBrands] = useState([]);
const [categories, setCategories] = useState([]);
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [productsLoading, setProductsLoading] = useState(false);
const [error, setError] = useState(null);
// Filters
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('');
const [selectedBrand, setSelectedBrand] = useState('');
const [stockFilter, setStockFilter] = useState('in_stock');
const [showFilters, setShowFilters] = useState(false);
// Pagination
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true);
const LIMIT = 24;
// Fetch dispensary data
useEffect(() => {
const fetchDispensaryData = async () => {
try {
setLoading(true);
setError(null);
// First get dispensary by slug
const dispRes = await getDispensaryBySlug(slug);
const mappedDispensary = mapDispensaryForUI(dispRes);
setDispensary(mappedDispensary);
// Then fetch related data using the dispensary ID
const dispensaryId = mappedDispensary.id;
const [summaryRes, brandsRes, catsRes] = await Promise.all([
getDispensarySummary(dispensaryId).catch(() => null),
getDispensaryBrands(dispensaryId).catch(() => []),
getDispensaryCategories(dispensaryId).catch(() => []),
]);
setSummary(summaryRes);
setBrands(brandsRes.brands || brandsRes || []);
setCategories(catsRes.categories || catsRes || []);
} catch (err) {
console.error('Error fetching dispensary:', err);
setError(err.message || 'Failed to load dispensary');
} finally {
setLoading(false);
}
};
fetchDispensaryData();
}, [slug]);
// Fetch products when filters change
useEffect(() => {
const fetchProducts = async () => {
if (!dispensary) return;
try {
setProductsLoading(true);
const res = await getStoreProducts(dispensary.id, {
search: searchTerm || undefined,
type: selectedCategory || undefined,
brandName: selectedBrand || undefined,
stockStatus: stockFilter || undefined,
limit: LIMIT,
offset: 0,
});
const mapped = (res.products || []).map(mapProductForUI);
setProducts(mapped);
setOffset(0);
setHasMore(mapped.length === LIMIT);
} catch (err) {
console.error('Error fetching products:', err);
} finally {
setProductsLoading(false);
}
};
fetchProducts();
}, [dispensary, searchTerm, selectedCategory, selectedBrand, stockFilter]);
const loadMore = async () => {
if (productsLoading || !hasMore || !dispensary) return;
try {
setProductsLoading(true);
const newOffset = offset + LIMIT;
const res = await getStoreProducts(dispensary.id, {
search: searchTerm || undefined,
type: selectedCategory || undefined,
brandName: selectedBrand || undefined,
stockStatus: stockFilter || undefined,
limit: LIMIT,
offset: newOffset,
});
const mapped = (res.products || []).map(mapProductForUI);
setProducts((prev) => [...prev, ...mapped]);
setOffset(newOffset);
setHasMore(mapped.length === LIMIT);
} catch (err) {
console.error('Error loading more products:', err);
} finally {
setProductsLoading(false);
}
};
const clearFilters = () => {
setSearchTerm('');
setSelectedCategory('');
setSelectedBrand('');
setStockFilter('in_stock');
};
const hasActiveFilters = searchTerm || selectedCategory || selectedBrand || stockFilter !== 'in_stock';
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-12 w-12 animate-spin text-primary mx-auto mb-4" />
<p className="text-gray-600">Loading dispensary...</p>
</div>
</div>
);
}
if (error || !dispensary) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Dispensary Not Found</h2>
<p className="text-gray-600 mb-4">
{error || "The dispensary you're looking for doesn't exist."}
</p>
<Link to="/">
<Button>Back to Home</Button>
</Link>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Breadcrumb */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
<nav className="flex items-center space-x-2 text-sm">
<Link to="/" className="text-gray-500 hover:text-primary">
Home
</Link>
<ChevronRight className="h-4 w-4 text-gray-400" />
<Link to="/dispensaries" className="text-gray-500 hover:text-primary">
Dispensaries
</Link>
<ChevronRight className="h-4 w-4 text-gray-400" />
<span className="text-gray-900 truncate max-w-[200px]">{dispensary.name}</span>
</nav>
</div>
</div>
{/* Dispensary Header */}
<section className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Left: Main Info */}
<div className="flex-1">
<div className="flex items-start gap-4">
{/* Dispensary Image/Icon */}
<div className="w-20 h-20 rounded-xl bg-gradient-to-br from-green-500 to-green-600 flex items-center justify-center shrink-0">
{dispensary.imageUrl ? (
<img
src={dispensary.imageUrl}
alt={dispensary.name}
className="w-full h-full object-cover rounded-xl"
onError={(e) => {
e.target.style.display = 'none';
}}
/>
) : (
<Store className="h-10 w-10 text-white" />
)}
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">
{dispensary.name}
</h1>
{/* Location */}
<div className="flex items-center text-gray-600 mb-3">
<MapPin className="h-4 w-4 mr-1 shrink-0" />
<span className="truncate">
{dispensary.address && `${dispensary.address}, `}
{dispensary.city}, {dispensary.state} {dispensary.zip}
</span>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-2">
{dispensary.licenseType?.recreational && (
<Badge className="bg-green-100 text-green-800">Recreational</Badge>
)}
{dispensary.licenseType?.medical && (
<Badge className="bg-blue-100 text-blue-800">Medical</Badge>
)}
{dispensary.services?.delivery && (
<Badge variant="outline" className="flex items-center gap-1">
<Truck className="h-3 w-3" />
Delivery
</Badge>
)}
{dispensary.services?.pickup && (
<Badge variant="outline" className="flex items-center gap-1">
<ShoppingBag className="h-3 w-3" />
Pickup
</Badge>
)}
{dispensary.services?.curbside && (
<Badge variant="outline" className="flex items-center gap-1">
<Car className="h-3 w-3" />
Curbside
</Badge>
)}
</div>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6">
<Card>
<CardContent className="p-4 text-center">
<Package className="h-6 w-6 text-primary mx-auto mb-1" />
<p className="text-2xl font-bold text-gray-900">
{summary?.productCount || dispensary.productCount || 0}
</p>
<p className="text-xs text-gray-500">Products</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<Tag className="h-6 w-6 text-primary mx-auto mb-1" />
<p className="text-2xl font-bold text-gray-900">
{brands.length || dispensary.brandCount || 0}
</p>
<p className="text-xs text-gray-500">Brands</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<Filter className="h-6 w-6 text-primary mx-auto mb-1" />
<p className="text-2xl font-bold text-gray-900">
{categories.length || dispensary.categoryCount || 0}
</p>
<p className="text-xs text-gray-500">Categories</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4 text-center">
<ShoppingBag className="h-6 w-6 text-green-500 mx-auto mb-1" />
<p className="text-2xl font-bold text-gray-900">
{summary?.inStockCount || dispensary.inStockCount || 0}
</p>
<p className="text-xs text-gray-500">In Stock</p>
</CardContent>
</Card>
</div>
</div>
{/* Right: Contact Info */}
<div className="lg:w-80">
<Card>
<CardContent className="p-6 space-y-4">
<h3 className="font-semibold text-gray-900">Store Info</h3>
{dispensary.website && (
<a
href={dispensary.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-primary hover:underline"
>
<Globe className="h-4 w-4 mr-2" />
Visit Website
</a>
)}
{dispensary.menuUrl && (
<a
href={dispensary.menuUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-primary hover:underline"
>
<ShoppingBag className="h-4 w-4 mr-2" />
View Full Menu
</a>
)}
<div className="flex items-start text-sm text-gray-600">
<MapPin className="h-4 w-4 mr-2 mt-0.5 shrink-0" />
<div>
{dispensary.address && <p>{dispensary.address}</p>}
<p>
{dispensary.city}, {dispensary.state} {dispensary.zip}
</p>
</div>
</div>
{/* Map placeholder */}
{dispensary.latitude && dispensary.longitude && (
<a
href={`https://www.google.com/maps/search/?api=1&query=${dispensary.latitude},${dispensary.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<div className="h-32 bg-gray-100 rounded-lg flex items-center justify-center hover:bg-gray-200 transition-colors">
<div className="text-center">
<MapPin className="h-8 w-8 text-gray-400 mx-auto mb-1" />
<span className="text-xs text-gray-500">View on Map</span>
</div>
</div>
</a>
)}
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* Products Section */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Filters Sidebar - Desktop */}
<div className="hidden lg:block lg:w-64 shrink-0">
<div className="sticky top-4 space-y-6">
<Card>
<CardContent className="p-4">
<h3 className="font-semibold text-gray-900 mb-4">Filters</h3>
{/* Search */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
className="w-full pl-9 pr-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
{/* Category */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.type || cat} value={cat.type || cat}>
{cat.type || cat}
</option>
))}
</select>
</div>
{/* Brand */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Brand
</label>
<select
value={selectedBrand}
onChange={(e) => setSelectedBrand(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">All Brands</option>
{brands.map((brand) => (
<option key={brand.brand_name || brand} value={brand.brand_name || brand}>
{brand.brand_name || brand}
</option>
))}
</select>
</div>
{/* Stock Status */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">
Availability
</label>
<select
value={stockFilter}
onChange={(e) => setStockFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="in_stock">In Stock</option>
<option value="">All Products</option>
<option value="out_of_stock">Out of Stock</option>
</select>
</div>
{hasActiveFilters && (
<Button
variant="outline"
size="sm"
onClick={clearFilters}
className="w-full"
>
<X className="h-4 w-4 mr-1" />
Clear Filters
</Button>
)}
</CardContent>
</Card>
</div>
</div>
{/* Main Content */}
<div className="flex-1">
{/* Mobile Filter Toggle */}
<div className="lg:hidden mb-4">
<Button
variant="outline"
onClick={() => setShowFilters(!showFilters)}
className="w-full justify-between"
>
<span className="flex items-center">
<Filter className="h-4 w-4 mr-2" />
Filters
{hasActiveFilters && (
<Badge className="ml-2 bg-primary text-white">Active</Badge>
)}
</span>
<ChevronDown className={`h-4 w-4 transition-transform ${showFilters ? 'rotate-180' : ''}`} />
</Button>
{/* Mobile Filters */}
{showFilters && (
<Card className="mt-2">
<CardContent className="p-4 space-y-4">
{/* Search */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Search
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search products..."
className="w-full pl-9 pr-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">All</option>
{categories.map((cat) => (
<option key={cat.type || cat} value={cat.type || cat}>
{cat.type || cat}
</option>
))}
</select>
</div>
{/* Brand */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Brand
</label>
<select
value={selectedBrand}
onChange={(e) => setSelectedBrand(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">All</option>
{brands.map((brand) => (
<option key={brand.brand_name || brand} value={brand.brand_name || brand}>
{brand.brand_name || brand}
</option>
))}
</select>
</div>
</div>
{/* Stock Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Availability
</label>
<select
value={stockFilter}
onChange={(e) => setStockFilter(e.target.value)}
className="w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="in_stock">In Stock</option>
<option value="">All Products</option>
<option value="out_of_stock">Out of Stock</option>
</select>
</div>
{hasActiveFilters && (
<Button
variant="outline"
size="sm"
onClick={clearFilters}
className="w-full"
>
<X className="h-4 w-4 mr-1" />
Clear Filters
</Button>
)}
</CardContent>
</Card>
)}
</div>
{/* Results Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">
Products
{products.length > 0 && (
<span className="text-gray-500 font-normal ml-2">
({products.length}{hasMore ? '+' : ''})
</span>
)}
</h2>
</div>
{/* Products Grid */}
{productsLoading && products.length === 0 ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : products.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
showDispensaryCount={false}
/>
))}
</div>
{/* Load More */}
{hasMore && (
<div className="text-center mt-8">
<Button
variant="outline"
onClick={loadMore}
disabled={productsLoading}
>
{productsLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Loading...
</>
) : (
'Load More Products'
)}
</Button>
</div>
)}
</>
) : (
<div className="text-center py-12 bg-white rounded-lg">
<Package className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 mb-4">
{hasActiveFilters
? 'No products match your filters.'
: 'No products available at this dispensary.'}
</p>
{hasActiveFilters && (
<Button variant="outline" onClick={clearFilters}>
Clear Filters
</Button>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default DispensaryDetail;

View File

@@ -1,26 +1,85 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import ProductCard from '../../components/findagram/ProductCard';
import { mockFavorites, mockProducts } from '../../mockData';
import { Heart, Trash2 } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { getFavorites, removeFavorite } from '../../api/consumer';
import { Heart, Trash2, Loader2 } from 'lucide-react';
const Favorites = () => {
const [favorites, setFavorites] = useState(mockFavorites);
const { isAuthenticated, authFetch, requireAuth } = useAuth();
const navigate = useNavigate();
const favoriteProducts = mockProducts.filter((p) => favorites.includes(p.id));
const [favorites, setFavorites] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [removingId, setRemovingId] = useState(null);
const toggleFavorite = (productId) => {
setFavorites((prev) =>
prev.includes(productId)
? prev.filter((id) => id !== productId)
: [...prev, productId]
// Redirect to home if not authenticated
useEffect(() => {
if (!isAuthenticated) {
requireAuth(() => navigate('/dashboard/favorites'));
}
}, [isAuthenticated, requireAuth, navigate]);
// Fetch favorites
useEffect(() => {
if (!isAuthenticated) return;
const fetchFavorites = async () => {
setLoading(true);
setError(null);
try {
const data = await getFavorites(authFetch);
setFavorites(data.favorites || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchFavorites();
}, [isAuthenticated, authFetch]);
const handleRemoveFavorite = async (favoriteId) => {
setRemovingId(favoriteId);
try {
await removeFavorite(authFetch, favoriteId);
setFavorites(prev => prev.filter(f => f.id !== favoriteId));
} catch (err) {
setError(err.message);
} finally {
setRemovingId(null);
}
};
const clearAllFavorites = async () => {
if (!window.confirm('Are you sure you want to remove all favorites?')) return;
setLoading(true);
try {
// Remove all favorites one by one
await Promise.all(favorites.map(f => removeFavorite(authFetch, f.id)));
setFavorites([]);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (!isAuthenticated) {
return null;
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
};
const clearAllFavorites = () => {
setFavorites([]);
};
}
return (
<div className="min-h-screen bg-gray-50">
@@ -34,10 +93,10 @@ const Favorites = () => {
My Favorites
</h1>
<p className="text-gray-600 mt-2">
{favoriteProducts.length} {favoriteProducts.length === 1 ? 'product' : 'products'} saved
{favorites.length} {favorites.length === 1 ? 'product' : 'products'} saved
</p>
</div>
{favoriteProducts.length > 0 && (
{favorites.length > 0 && (
<Button
variant="outline"
onClick={clearAllFavorites}
@@ -52,15 +111,86 @@ const Favorites = () => {
</section>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{favoriteProducts.length > 0 ? (
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
{error}
</div>
)}
{favorites.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{favoriteProducts.map((product) => (
<ProductCard
key={product.id}
product={product}
onFavorite={toggleFavorite}
isFavorite={true}
/>
{favorites.map((fav) => (
<div
key={fav.id}
className="bg-white rounded-lg shadow-sm border overflow-hidden group"
>
<Link to={`/products/${fav.productId}`}>
<div className="relative aspect-square overflow-hidden bg-gray-100">
<img
src={fav.imageUrl || '/placeholder-product.jpg'}
alt={fav.savedName}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
{fav.priceDrop && (
<div className="absolute top-2 left-2 bg-green-500 text-white text-xs px-2 py-1 rounded">
Price dropped!
</div>
)}
</div>
</Link>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
{fav.currentBrand || fav.savedBrand}
</p>
<Link to={`/products/${fav.productId}`}>
<h3 className="font-semibold text-gray-900 line-clamp-2 hover:text-primary transition-colors mb-2">
{fav.currentName || fav.savedName}
</h3>
</Link>
{fav.dispensaryName && (
<p className="text-sm text-gray-500 mb-2">
at {fav.dispensaryName}
</p>
)}
<div className="flex items-center justify-between">
<div>
{fav.currentPrice ? (
<p className="text-lg font-bold text-primary">
${parseFloat(fav.currentPrice).toFixed(2)}
</p>
) : fav.savedPrice ? (
<p className="text-lg font-bold text-gray-600">
${parseFloat(fav.savedPrice).toFixed(2)}
<span className="text-xs text-gray-400 ml-1">(saved)</span>
</p>
) : (
<p className="text-sm text-gray-400">No price</p>
)}
{fav.priceDrop && fav.savedPrice && fav.currentPrice && (
<p className="text-xs text-green-600">
Was ${parseFloat(fav.savedPrice).toFixed(2)}
</p>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveFavorite(fav.id)}
disabled={removingId === fav.id}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
>
{removingId === fav.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Heart className="h-4 w-4 fill-current" />
)}
</Button>
</div>
</div>
</div>
))}
</div>
) : (

View File

@@ -10,10 +10,14 @@ import {
getDeals,
getCategories,
getBrands,
getDispensaries,
getStats,
mapProductForUI,
mapCategoryForUI,
mapBrandForUI,
mapDispensaryForUI,
} from '../../api/client';
import { useGeolocation, sortByDistance } from '../../hooks/useGeolocation';
import {
Search,
Leaf,
@@ -26,6 +30,9 @@ import {
ShoppingBag,
MapPin,
Loader2,
Navigation,
Clock,
Store,
} from 'lucide-react';
const Home = () => {
@@ -33,17 +40,22 @@ const Home = () => {
const [searchQuery, setSearchQuery] = useState('');
const [favorites, setFavorites] = useState([]);
// Geolocation - auto-request on mount
const { location, loading: locationLoading, error: locationError, requestLocation, hasPermission } = useGeolocation({ autoRequest: true });
// API state
const [featuredProducts, setFeaturedProducts] = useState([]);
const [dealsProducts, setDealsProducts] = useState([]);
const [categories, setCategories] = useState([]);
const [brands, setBrands] = useState([]);
const [nearbyDispensaries, setNearbyDispensaries] = useState([]);
const [stats, setStats] = useState({
products: 0,
brands: 0,
dispensaries: 0,
});
const [loading, setLoading] = useState(true);
const [dispensariesLoading, setDispensariesLoading] = useState(false);
// Fetch data on mount
useEffect(() => {
@@ -52,11 +64,12 @@ const Home = () => {
setLoading(true);
// Fetch all data in parallel
const [productsRes, dealsRes, categoriesRes, brandsRes] = await Promise.all([
const [productsRes, dealsRes, categoriesRes, brandsRes, statsRes] = await Promise.all([
getProducts({ limit: 4 }),
getDeals({ limit: 4 }),
getCategories(),
getBrands({ limit: 100 }),
getBrands({ limit: 500 }),
getStats(),
]);
// Set featured products
@@ -75,15 +88,17 @@ const Home = () => {
);
// Set brands (first 6 as popular)
const allBrands = brandsRes.brands || [];
setBrands(
(brandsRes.brands || []).slice(0, 6).map(mapBrandForUI)
allBrands.slice(0, 6).map(mapBrandForUI)
);
// Set stats
// Set stats from dedicated stats endpoint
const statsData = statsRes.stats || {};
setStats({
products: productsRes.pagination?.total || 0,
brands: brandsRes.pagination?.total || 0,
dispensaries: 200, // Hardcoded for now - could add API endpoint
products: statsData.products || 0,
brands: statsData.brands || 0,
dispensaries: statsData.dispensaries || 0,
});
} catch (err) {
console.error('Error fetching home data:', err);
@@ -95,6 +110,35 @@ const Home = () => {
fetchData();
}, []);
// Fetch nearby dispensaries when location is available
useEffect(() => {
const fetchNearbyDispensaries = async () => {
if (!location) return;
try {
setDispensariesLoading(true);
// Fetch dispensaries with products
const res = await getDispensaries({ limit: 100, hasProducts: true });
const dispensaries = (res.dispensaries || []).map(mapDispensaryForUI);
// Sort by distance from user
const sorted = sortByDistance(dispensaries, location, (d) => ({
latitude: d.latitude,
longitude: d.longitude
}));
// Take top 6 nearest
setNearbyDispensaries(sorted.slice(0, 6));
} catch (err) {
console.error('Error fetching nearby dispensaries:', err);
} finally {
setDispensariesLoading(false);
}
};
fetchNearbyDispensaries();
}, [location]);
const handleSearch = (e) => {
e.preventDefault();
if (searchQuery.trim()) {
@@ -203,8 +247,136 @@ const Home = () => {
</div>
</section>
{/* Featured Products */}
{/* Nearby Dispensaries Section */}
<section className="py-12 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<MapPin className="h-6 w-6 text-primary" />
{location ? 'Dispensaries Near You' : 'Find Dispensaries Near You'}
</h2>
<p className="text-gray-600 mt-1">
{location
? 'Sorted by distance from your location'
: 'Enable location to see nearby stores'}
</p>
</div>
<div className="flex items-center gap-3">
{!location && (
<Button
onClick={requestLocation}
disabled={locationLoading}
variant="outline"
className="text-primary border-primary"
>
{locationLoading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Navigation className="h-4 w-4 mr-2" />
)}
Use My Location
</Button>
)}
<Link to="/dispensaries">
<Button variant="ghost" className="text-primary">
View All
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</div>
{dispensariesLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : nearbyDispensaries.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{nearbyDispensaries.map((dispensary) => (
<Link
key={dispensary.id}
to={`/dispensaries/${dispensary.slug || dispensary.id}`}
className="group"
>
<Card className="h-full transition-all hover:shadow-lg hover:-translate-y-1">
<CardContent className="p-5">
<div className="flex items-start gap-4">
<div className="w-14 h-14 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0 overflow-hidden">
{dispensary.imageUrl ? (
<img
src={dispensary.imageUrl}
alt={dispensary.name}
className="w-full h-full object-cover"
/>
) : (
<Store className="h-7 w-7 text-gray-400" />
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 group-hover:text-primary transition-colors truncate">
{dispensary.name}
</h3>
<p className="text-sm text-gray-500 mt-1 flex items-center gap-1">
<MapPin className="h-3 w-3" />
{dispensary.city}, {dispensary.state}
</p>
{dispensary.distance !== null && (
<p className="text-sm text-primary font-medium mt-1">
{dispensary.distance} mi away
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 mt-4 pt-4 border-t">
{dispensary.productCount > 0 && (
<Badge variant="secondary" className="text-xs">
{dispensary.productCount} products
</Badge>
)}
{dispensary.rating && (
<Badge variant="outline" className="text-xs flex items-center gap-1">
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
{dispensary.rating}
</Badge>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : location ? (
<div className="text-center py-8">
<Store className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">No dispensaries found nearby</p>
</div>
) : (
<div className="text-center py-8 bg-white rounded-lg border-2 border-dashed border-gray-200">
<Navigation className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-600 font-medium mb-2">Find dispensaries near you</p>
<p className="text-gray-500 text-sm mb-4 max-w-md mx-auto">
We'll use your location to show the closest dispensaries, their products, and prices. Your location is never stored or shared.
</p>
<Button
onClick={requestLocation}
disabled={locationLoading}
className="gradient-purple"
>
{locationLoading ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<MapPin className="h-4 w-4 mr-2" />
)}
Share My Location
</Button>
</div>
)}
</div>
</section>
{/* Featured Products */}
<section className="py-12 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-8">
<div>
@@ -236,7 +408,7 @@ const Home = () => {
</section>
{/* Deals Section */}
<section className="py-12 bg-white">
<section className="py-12 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-8">
<div>
@@ -280,7 +452,7 @@ const Home = () => {
</section>
{/* Browse by Category */}
<section className="py-12 bg-gray-50">
<section className="py-12 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900">Browse by Category</h2>
@@ -320,7 +492,7 @@ const Home = () => {
</section>
{/* Popular Brands */}
<section className="py-12 bg-white">
<section className="py-12 bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between mb-8">
<div>

View File

@@ -1,23 +1,84 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button } from '../../components/ui/button';
import { Card, CardContent } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge';
import { mockSavedSearches } from '../../mockData';
import { Bookmark, Search, Trash2, ChevronRight } from 'lucide-react';
import { useAuth } from '../../context/AuthContext';
import { getSavedSearches, deleteSavedSearch } from '../../api/consumer';
import { Bookmark, Search, Trash2, ChevronRight, Loader2 } from 'lucide-react';
const SavedSearches = () => {
const [searches, setSearches] = useState(mockSavedSearches);
const { isAuthenticated, authFetch, requireAuth } = useAuth();
const navigate = useNavigate();
const deleteSearch = (searchId) => {
setSearches((prev) => prev.filter((search) => search.id !== searchId));
const [searches, setSearches] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [deletingId, setDeletingId] = useState(null);
// Redirect to home if not authenticated
useEffect(() => {
if (!isAuthenticated) {
requireAuth(() => navigate('/dashboard/searches'));
}
}, [isAuthenticated, requireAuth, navigate]);
// Fetch saved searches
useEffect(() => {
if (!isAuthenticated) return;
const fetchSearches = async () => {
setLoading(true);
setError(null);
try {
const data = await getSavedSearches(authFetch);
setSearches(data.savedSearches || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchSearches();
}, [isAuthenticated, authFetch]);
const handleDeleteSearch = async (searchId) => {
setDeletingId(searchId);
try {
await deleteSavedSearch(authFetch, searchId);
setSearches(prev => prev.filter(s => s.id !== searchId));
} catch (err) {
setError(err.message);
} finally {
setDeletingId(null);
}
};
const buildSearchUrl = (filters) => {
const params = new URLSearchParams(filters);
const buildSearchUrl = (search) => {
const params = new URLSearchParams();
if (search.query) params.set('search', search.query);
if (search.category) params.set('type', search.category);
if (search.brand) params.set('brandName', search.brand);
if (search.strainType) params.set('strainType', search.strainType);
if (search.minPrice) params.set('minPrice', search.minPrice);
if (search.maxPrice) params.set('maxPrice', search.maxPrice);
return `/products?${params.toString()}`;
};
if (!isAuthenticated) {
return null;
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
@@ -44,6 +105,12 @@ const SavedSearches = () => {
</section>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-600">
{error}
</div>
)}
{searches.length > 0 ? (
<div className="space-y-4">
{searches.map((search) => (
@@ -56,31 +123,33 @@ const SavedSearches = () => {
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900">{search.name}</h3>
<div className="flex flex-wrap gap-2 mt-2">
{search.filters.category && (
<Badge variant="secondary">{search.filters.category}</Badge>
{search.query && (
<Badge variant="secondary">"{search.query}"</Badge>
)}
{search.filters.strainType && (
<Badge variant="outline">{search.filters.strainType}</Badge>
{search.category && (
<Badge variant="secondary">{search.category}</Badge>
)}
{search.filters.priceMax && (
<Badge variant="outline">Under ${search.filters.priceMax}</Badge>
{search.brand && (
<Badge variant="outline">{search.brand}</Badge>
)}
{search.filters.thcMin && (
<Badge variant="outline">THC {search.filters.thcMin}%+</Badge>
{search.strainType && (
<Badge variant="outline">{search.strainType}</Badge>
)}
{search.filters.search && (
<Badge variant="secondary">"{search.filters.search}"</Badge>
{search.maxPrice && (
<Badge variant="outline">Under ${search.maxPrice}</Badge>
)}
{search.minThc && (
<Badge variant="outline">THC {search.minThc}%+</Badge>
)}
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-500">{search.resultCount} results</p>
<p className="text-xs text-gray-400 mt-1">
<p className="text-xs text-gray-400">
Saved {new Date(search.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center gap-2">
<Link to={buildSearchUrl(search.filters)}>
<Link to={buildSearchUrl(search)}>
<Button variant="outline" size="sm">
Run Search
<ChevronRight className="h-4 w-4 ml-1" />
@@ -89,11 +158,16 @@ const SavedSearches = () => {
<Button
variant="ghost"
size="icon"
onClick={() => deleteSearch(search.id)}
onClick={() => handleDeleteSearch(search.id)}
disabled={deletingId === search.id}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
title="Delete search"
>
<Trash2 className="h-4 w-4" />
{deletingId === search.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>

View File

@@ -1,65 +1,162 @@
# Hydration Worker Deployment
# These workers process raw_payloads → canonical tables.
# Scale this deployment to increase hydration throughput.
# Task Worker Pods
# Each pod runs 5 role-agnostic workers that pull tasks from worker_tasks queue.
#
# Architecture:
# - The main 'scraper' deployment runs the API server + scheduler (1 replica)
# - This 'scraper-worker' deployment runs hydration workers (5 replicas)
# - Workers use DB-level locking to prevent double-processing
# - Each worker processes payloads in batches with configurable limits
apiVersion: apps/v1
kind: Deployment
# - Pods are named from a predefined list (Aethelgard, Xylos, etc.)
# - Each pod spawns 5 worker processes
# - Workers register with API and show their pod name
# - HPA scales pods 5-15 based on pending task count
# - Workers use DB-level locking (FOR UPDATE SKIP LOCKED) to prevent conflicts
#
# Pod Names (up to 25):
# Aethelgard, Xylos, Kryll, Coriolis, Dimidium, Veridia, Zetani, Talos IV,
# Onyx, Celestia, Gormand, Betha, Ragnar, Syphon, Axiom, Nadir, Terra Nova,
# Acheron, Nexus, Vespera, Helios Prime, Oasis, Mordina, Cygnus, Umbra
---
apiVersion: v1
kind: ConfigMap
metadata:
name: scraper-worker
name: pod-names
namespace: dispensary-scraper
data:
names: |
Aethelgard
Xylos
Kryll
Coriolis
Dimidium
Veridia
Zetani
Talos IV
Onyx
Celestia
Gormand
Betha
Ragnar
Syphon
Axiom
Nadir
Terra Nova
Acheron
Nexus
Vespera
Helios Prime
Oasis
Mordina
Cygnus
Umbra
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: worker-pod
namespace: dispensary-scraper
spec:
serviceName: worker-pods
replicas: 5
podManagementPolicy: Parallel
selector:
matchLabels:
app: scraper-worker
app: worker-pod
template:
metadata:
labels:
app: scraper-worker
app: worker-pod
spec:
imagePullSecrets:
- name: regcred
containers:
- name: worker
- name: workers
image: code.cannabrands.app/creationshop/dispensary-scraper:latest
# Run the hydration worker in loop mode
command: ["node"]
args: ["dist/scripts/run-hydration.js", "--mode=payload", "--loop"]
# Run 5 workers per pod
command: ["/bin/sh", "-c"]
args:
- |
# Get pod ordinal (0, 1, 2, etc.)
ORDINAL=$(echo $HOSTNAME | rev | cut -d'-' -f1 | rev)
# Get pod name from configmap
POD_NAME=$(sed -n "$((ORDINAL + 1))p" /etc/pod-names/names)
echo "Starting pod: $POD_NAME (ordinal: $ORDINAL)"
# Start 5 workers in this pod
for i in 1 2 3 4 5; do
WORKER_ID="${POD_NAME}-worker-${i}" \
POD_NAME="$POD_NAME" \
node dist/tasks/task-worker.js &
done
# Wait for all workers
wait
envFrom:
- configMapRef:
name: scraper-config
- secretRef:
name: scraper-secrets
env:
# Worker-specific environment variables
- name: WORKER_MODE
value: "true"
# Pod name becomes part of worker ID for debugging
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: API_BASE_URL
value: "http://scraper:3010"
- name: WORKERS_PER_POD
value: "5"
volumeMounts:
- name: pod-names
mountPath: /etc/pod-names
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
# Health check - workers don't expose ports, but we can use a file check
cpu: "200m"
limits:
memory: "1Gi"
cpu: "1000m"
livenessProbe:
exec:
command:
- /bin/sh
- -c
- "pgrep -f 'run-hydration' > /dev/null"
initialDelaySeconds: 10
- "pgrep -f 'task-worker' > /dev/null"
initialDelaySeconds: 15
periodSeconds: 30
failureThreshold: 3
# Graceful shutdown - give workers time to complete current batch
volumes:
- name: pod-names
configMap:
name: pod-names
terminationGracePeriodSeconds: 60
---
# Headless service for StatefulSet
apiVersion: v1
kind: Service
metadata:
name: worker-pods
namespace: dispensary-scraper
spec:
clusterIP: None
selector:
app: worker-pod
ports:
- port: 80
name: placeholder
---
# HPA to scale pods based on pending tasks
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: worker-pod-hpa
namespace: dispensary-scraper
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: StatefulSet
name: worker-pod
minReplicas: 5
maxReplicas: 15
metrics:
- type: External
external:
metric:
name: pending_tasks
selector:
matchLabels:
queue: worker_tasks
target:
type: AverageValue
averageValue: "10"

View File

@@ -1 +1 @@
1.5.4
1.6.0

View File

@@ -312,3 +312,184 @@
border-radius: 4px;
border-left: 4px solid #c62828;
}
/* ========================================
Brand Grid Widget
======================================== */
.cannaiq-brand-grid {
display: grid;
gap: 20px;
margin: 20px 0;
}
.cannaiq-brand-card {
background: #fff;
border-radius: 8px;
padding: 20px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.cannaiq-brand-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.cannaiq-brand-name {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
color: #333;
}
.cannaiq-brand-count {
font-size: 13px;
color: #666;
}
/* ========================================
Category List Widget
======================================== */
.cannaiq-category-grid {
display: grid;
gap: 16px;
margin: 20px 0;
}
.cannaiq-category-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 20px 0;
}
.cannaiq-category-pills {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0;
}
.cannaiq-category-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #fff;
border-radius: 8px;
text-decoration: none;
color: #333;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: background 0.2s, transform 0.2s;
}
.cannaiq-category-item:hover {
background: #f3f4f6;
transform: translateX(4px);
}
.cannaiq-category-pills-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: #f3f4f6;
border-radius: 20px;
text-decoration: none;
color: #333;
font-size: 14px;
transition: background 0.2s;
}
.cannaiq-category-pills-item:hover {
background: #e5e7eb;
}
.cannaiq-category-name {
font-weight: 500;
}
.cannaiq-category-count {
font-size: 13px;
color: #666;
}
/* ========================================
Specials/Deals Grid Widget
======================================== */
.cannaiq-specials-grid {
display: grid;
gap: 24px;
margin: 20px 0;
}
.cannaiq-special-card {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
position: relative;
}
.cannaiq-special-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.cannaiq-discount-badge {
position: absolute;
top: 12px;
right: 12px;
background: #ef4444;
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 4px 10px;
border-radius: 4px;
z-index: 1;
}
.cannaiq-special-image {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: #f5f5f5;
}
.cannaiq-special-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cannaiq-special-content {
padding: 16px;
}
.cannaiq-special-title {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
color: #333;
line-height: 1.4;
}
.cannaiq-special-price {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.cannaiq-special-price .cannaiq-price-sale {
font-size: 20px;
font-weight: 700;
color: #16a34a;
}
.cannaiq-special-price .cannaiq-price-regular {
font-size: 14px;
color: #999;
}

View File

@@ -3,7 +3,7 @@
* Plugin Name: CannaIQ Menus
* Plugin URI: https://cannaiq.co
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily.
* Version: 1.5.4
* Version: 1.6.0
* Author: CannaIQ
* Author URI: https://cannaiq.co
* License: GPL v2 or later
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
define('CANNAIQ_MENUS_VERSION', '1.5.4');
define('CANNAIQ_MENUS_VERSION', '1.6.0');
define('CANNAIQ_MENUS_API_URL', 'https://cannaiq.co/api/v1');
define('CANNAIQ_MENUS_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('CANNAIQ_MENUS_PLUGIN_URL', plugin_dir_url(__FILE__));
@@ -62,9 +62,15 @@ class CannaIQ_Menus_Plugin {
public function register_elementor_widgets($widgets_manager) {
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-grid.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/single-product.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/brand-grid.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/category-list.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/specials-grid.php';
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Single_Product_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Brand_Grid_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Category_List_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Specials_Grid_Widget());
}
/**
@@ -392,6 +398,152 @@ class CannaIQ_Menus_Plugin {
return $data['product'] ?? false;
}
/**
* Fetch Categories from API
*/
public function fetch_categories($args = []) {
$api_token = get_option('cannaiq_api_token');
if (!$api_token) {
return false;
}
$query_args = http_build_query($args);
$url = CANNAIQ_MENUS_API_URL . '/categories' . ($query_args ? '?' . $query_args : '');
$response = wp_remote_get($url, [
'headers' => [
'X-API-Key' => $api_token
],
'timeout' => 30
]);
if (is_wp_error($response)) {
return false;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
return $data['categories'] ?? false;
}
/**
* Fetch Brands from API
*/
public function fetch_brands($args = []) {
$api_token = get_option('cannaiq_api_token');
if (!$api_token) {
return false;
}
$query_args = http_build_query($args);
$url = CANNAIQ_MENUS_API_URL . '/brands' . ($query_args ? '?' . $query_args : '');
$response = wp_remote_get($url, [
'headers' => [
'X-API-Key' => $api_token
],
'timeout' => 30
]);
if (is_wp_error($response)) {
return false;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
return $data['brands'] ?? false;
}
/**
* Fetch Specials/Deals from API
*/
public function fetch_specials($args = []) {
$api_token = get_option('cannaiq_api_token');
if (!$api_token) {
return false;
}
$query_args = http_build_query($args);
$url = CANNAIQ_MENUS_API_URL . '/specials' . ($query_args ? '?' . $query_args : '');
$response = wp_remote_get($url, [
'headers' => [
'X-API-Key' => $api_token
],
'timeout' => 30
]);
if (is_wp_error($response)) {
return false;
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
return $data['products'] ?? false;
}
/**
* Get categories as options for Elementor select control
* Returns cached results for performance
*/
public function get_category_options() {
$cache_key = 'cannaiq_category_options';
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$categories = $this->fetch_categories();
$options = ['' => __('All Categories', 'cannaiq-menus')];
if ($categories) {
foreach ($categories as $cat) {
$name = $cat['type'] ?? $cat['name'] ?? '';
if ($name) {
$options[$name] = ucwords(str_replace('_', ' ', $name));
}
}
}
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
return $options;
}
/**
* Get brands as options for Elementor select control
* Returns cached results for performance
*/
public function get_brand_options() {
$cache_key = 'cannaiq_brand_options';
$cached = get_transient($cache_key);
if ($cached !== false) {
return $cached;
}
$brands = $this->fetch_brands(['limit' => 200]);
$options = ['' => __('All Brands', 'cannaiq-menus')];
if ($brands) {
foreach ($brands as $brand) {
$name = $brand['brand'] ?? $brand['brand_name'] ?? '';
if ($name) {
$options[$name] = $name;
}
}
}
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
return $options;
}
}
// Initialize Plugin

View File

@@ -0,0 +1,184 @@
<?php
/**
* Elementor Brand Grid Widget
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Menus_Brand_Grid_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_brand_grid';
}
public function get_title() {
return __('CannaIQ Brand Grid', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-gallery-grid';
}
public function get_categories() {
return ['general'];
}
protected function register_controls() {
// Content Section
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'limit',
[
'label' => __('Number of Brands', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 12,
'min' => 1,
'max' => 100,
]
);
$this->add_control(
'columns',
[
'label' => __('Columns', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '4',
'options' => [
'2' => __('2 Columns', 'cannaiq-menus'),
'3' => __('3 Columns', 'cannaiq-menus'),
'4' => __('4 Columns', 'cannaiq-menus'),
'6' => __('6 Columns', 'cannaiq-menus'),
],
]
);
$this->add_control(
'show_product_count',
[
'label' => __('Show Product Count', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'link_to_products',
[
'label' => __('Link to Products Page', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::URL,
'placeholder' => __('/products', 'cannaiq-menus'),
'description' => __('Brand name will be appended as ?brand=Name', 'cannaiq-menus'),
]
);
$this->end_controls_section();
// Style Section
$this->start_controls_section(
'style_section',
[
'label' => __('Style', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_control(
'card_background',
[
'label' => __('Card Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-brand-card' => 'background-color: {{VALUE}};',
],
]
);
$this->add_control(
'card_border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 50,
],
],
'default' => [
'size' => 8,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-brand-card' => 'border-radius: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_control(
'text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#333333',
'selectors' => [
'{{WRAPPER}} .cannaiq-brand-card' => 'color: {{VALUE}};',
],
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$plugin = CannaIQ_Menus_Plugin::instance();
$brands = $plugin->fetch_brands(['limit' => $settings['limit']]);
if (!$brands) {
echo '<p>' . __('No brands found.', 'cannaiq-menus') . '</p>';
return;
}
$columns = $settings['columns'];
$link_base = $settings['link_to_products']['url'] ?? '';
?>
<div class="cannaiq-brand-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
<?php foreach ($brands as $brand):
$brand_name = $brand['brand'] ?? $brand['brand_name'] ?? '';
$product_count = $brand['product_count'] ?? 0;
$brand_url = $link_base ? $link_base . '?brand=' . urlencode($brand_name) : '#';
?>
<div class="cannaiq-brand-card"
<?php if ($brand_url !== '#'): ?>onclick="window.location.href='<?php echo esc_url($brand_url); ?>'"<?php endif; ?>
style="cursor: <?php echo ($brand_url !== '#') ? 'pointer' : 'default'; ?>;">
<div class="cannaiq-brand-content">
<h3 class="cannaiq-brand-name">
<?php echo esc_html($brand_name); ?>
</h3>
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
<span class="cannaiq-brand-count">
<?php echo esc_html($product_count); ?> <?php _e('products', 'cannaiq-menus'); ?>
</span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
}
}

View File

@@ -0,0 +1,205 @@
<?php
/**
* Elementor Category List Widget
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Menus_Category_List_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_category_list';
}
public function get_title() {
return __('CannaIQ Category List', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-bullet-list';
}
public function get_categories() {
return ['general'];
}
protected function register_controls() {
// Content Section
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'layout',
[
'label' => __('Layout', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'grid',
'options' => [
'grid' => __('Grid', 'cannaiq-menus'),
'list' => __('List', 'cannaiq-menus'),
'pills' => __('Pills/Tags', 'cannaiq-menus'),
],
]
);
$this->add_control(
'columns',
[
'label' => __('Columns', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '3',
'options' => [
'2' => __('2 Columns', 'cannaiq-menus'),
'3' => __('3 Columns', 'cannaiq-menus'),
'4' => __('4 Columns', 'cannaiq-menus'),
'6' => __('6 Columns', 'cannaiq-menus'),
],
'condition' => [
'layout' => 'grid',
],
]
);
$this->add_control(
'show_product_count',
[
'label' => __('Show Product Count', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'link_to_products',
[
'label' => __('Link to Products Page', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::URL,
'placeholder' => __('/products', 'cannaiq-menus'),
'description' => __('Category name will be appended as ?category=Name', 'cannaiq-menus'),
]
);
$this->end_controls_section();
// Style Section
$this->start_controls_section(
'style_section',
[
'label' => __('Style', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_control(
'card_background',
[
'label' => __('Card Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-category-item' => 'background-color: {{VALUE}};',
],
]
);
$this->add_control(
'card_border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 50,
],
],
'default' => [
'size' => 8,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-category-item' => 'border-radius: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_control(
'text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#333333',
'selectors' => [
'{{WRAPPER}} .cannaiq-category-item' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'hover_background',
[
'label' => __('Hover Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f3f4f6',
'selectors' => [
'{{WRAPPER}} .cannaiq-category-item:hover' => 'background-color: {{VALUE}};',
],
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$plugin = CannaIQ_Menus_Plugin::instance();
$categories = $plugin->fetch_categories();
if (!$categories) {
echo '<p>' . __('No categories found.', 'cannaiq-menus') . '</p>';
return;
}
$layout = $settings['layout'];
$columns = $settings['columns'];
$link_base = $settings['link_to_products']['url'] ?? '';
$container_class = 'cannaiq-category-' . $layout;
if ($layout === 'grid') {
$container_class .= ' cannaiq-grid-cols-' . $columns;
}
?>
<div class="<?php echo esc_attr($container_class); ?>">
<?php foreach ($categories as $category):
$cat_name = $category['type'] ?? $category['name'] ?? '';
$display_name = ucwords(str_replace('_', ' ', $cat_name));
$product_count = $category['product_count'] ?? 0;
$cat_url = $link_base ? $link_base . '?category=' . urlencode($cat_name) : '#';
?>
<a href="<?php echo esc_url($cat_url); ?>" class="cannaiq-category-item cannaiq-category-<?php echo esc_attr($layout); ?>-item">
<span class="cannaiq-category-name">
<?php echo esc_html($display_name); ?>
</span>
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
<span class="cannaiq-category-count">
(<?php echo esc_html($product_count); ?>)
</span>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
<?php
}
}

View File

@@ -47,12 +47,37 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
);
$this->add_control(
'category_id',
'category',
[
'label' => __('Category ID', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'label' => __('Category', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'description' => __('Leave empty to show all categories', 'cannaiq-menus'),
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
'description' => __('Filter by product category', 'cannaiq-menus'),
]
);
$this->add_control(
'brand',
[
'label' => __('Brand', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'options' => CannaIQ_Menus_Plugin::instance()->get_brand_options(),
'description' => __('Filter by brand', 'cannaiq-menus'),
]
);
$this->add_control(
'on_special',
[
'label' => __('On Special Only', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'no',
'description' => __('Show only products on sale', 'cannaiq-menus'),
]
);
@@ -243,8 +268,16 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
'in_stock' => $settings['in_stock_only'] === 'yes' ? 'true' : 'false',
];
if (!empty($settings['category_id'])) {
$args['category_id'] = $settings['category_id'];
if (!empty($settings['category'])) {
$args['type'] = $settings['category'];
}
if (!empty($settings['brand'])) {
$args['brandName'] = $settings['brand'];
}
if ($settings['on_special'] === 'yes') {
$args['on_special'] = 'true';
}
if (!empty($settings['search'])) {

View File

@@ -0,0 +1,288 @@
<?php
/**
* Elementor Specials/Deals Grid Widget
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Menus_Specials_Grid_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_specials_grid';
}
public function get_title() {
return __('CannaIQ Specials/Deals', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-price-table';
}
public function get_categories() {
return ['general'];
}
protected function register_controls() {
// Content Section
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'store_id',
[
'label' => __('Store ID', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => get_option('cannaiq_default_store_id', 1),
'min' => 1,
]
);
$this->add_control(
'limit',
[
'label' => __('Number of Products', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 8,
'min' => 1,
'max' => 50,
]
);
$this->add_control(
'columns',
[
'label' => __('Columns', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '4',
'options' => [
'2' => __('2 Columns', 'cannaiq-menus'),
'3' => __('3 Columns', 'cannaiq-menus'),
'4' => __('4 Columns', 'cannaiq-menus'),
],
]
);
$this->add_control(
'category',
[
'label' => __('Category', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
'description' => __('Filter specials by category', 'cannaiq-menus'),
]
);
$this->end_controls_section();
// Display Options Section
$this->start_controls_section(
'display_section',
[
'label' => __('Display Options', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'show_image',
[
'label' => __('Show Image', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_discount_badge',
[
'label' => __('Show Discount Badge', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_original_price',
[
'label' => __('Show Original Price', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_thc',
[
'label' => __('Show THC', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'no',
]
);
$this->end_controls_section();
// Style Section
$this->start_controls_section(
'style_section',
[
'label' => __('Style', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_control(
'card_background',
[
'label' => __('Card Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-special-card' => 'background-color: {{VALUE}};',
],
]
);
$this->add_control(
'badge_background',
[
'label' => __('Badge Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ef4444',
'selectors' => [
'{{WRAPPER}} .cannaiq-discount-badge' => 'background-color: {{VALUE}};',
],
]
);
$this->add_control(
'sale_price_color',
[
'label' => __('Sale Price Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#16a34a',
'selectors' => [
'{{WRAPPER}} .cannaiq-price-sale' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'card_border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 50,
],
],
'default' => [
'size' => 8,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-special-card' => 'border-radius: {{SIZE}}{{UNIT}};',
],
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$args = [
'store_id' => $settings['store_id'],
'limit' => $settings['limit'],
];
if (!empty($settings['category'])) {
$args['type'] = $settings['category'];
}
$plugin = CannaIQ_Menus_Plugin::instance();
$products = $plugin->fetch_specials($args);
if (!$products) {
echo '<p>' . __('No specials found.', 'cannaiq-menus') . '</p>';
return;
}
$columns = $settings['columns'];
?>
<div class="cannaiq-specials-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
<?php foreach ($products as $product):
$image_url = $product['image_url'] ?? $product['primary_image_url'] ?? '';
$product_url = !empty($product['menu_url']) ? $product['menu_url'] : '#';
$regular_price = $product['regular_price'] ?? 0;
$sale_price = $product['sale_price'] ?? $regular_price;
$discount = ($regular_price > 0 && $sale_price < $regular_price)
? round((($regular_price - $sale_price) / $regular_price) * 100)
: 0;
?>
<div class="cannaiq-special-card"
<?php if ($product_url !== '#'): ?>onclick="window.open('<?php echo esc_url($product_url); ?>', '_blank')"<?php endif; ?>
style="cursor: <?php echo ($product_url !== '#') ? 'pointer' : 'default'; ?>;">
<?php if ($settings['show_discount_badge'] === 'yes' && $discount > 0): ?>
<div class="cannaiq-discount-badge">
-<?php echo esc_html($discount); ?>%
</div>
<?php endif; ?>
<?php if ($settings['show_image'] === 'yes' && !empty($image_url)): ?>
<div class="cannaiq-special-image">
<img src="<?php echo esc_url($image_url); ?>"
alt="<?php echo esc_attr($product['name']); ?>"
loading="lazy" />
</div>
<?php endif; ?>
<div class="cannaiq-special-content">
<h3 class="cannaiq-special-title">
<?php echo esc_html($product['name']); ?>
</h3>
<?php if ($settings['show_thc'] === 'yes' && !empty($product['thc_percentage'])): ?>
<span class="cannaiq-meta-item cannaiq-thc">
THC: <?php echo esc_html($product['thc_percentage']); ?>%
</span>
<?php endif; ?>
<div class="cannaiq-special-price">
<span class="cannaiq-price-sale">$<?php echo esc_html($sale_price); ?></span>
<?php if ($settings['show_original_price'] === 'yes' && $regular_price > $sale_price): ?>
<span class="cannaiq-price-regular cannaiq-strikethrough">$<?php echo esc_html($regular_price); ?></span>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
}
}