feat: Responsive admin UI, SEO pages, and click analytics

## Responsive Admin UI
- Layout.tsx: Mobile sidebar drawer with hamburger menu
- Dashboard.tsx: 2-col grid on mobile, responsive stats cards
- OrchestratorDashboard.tsx: Responsive table with hidden columns
- PagesTab.tsx: Responsive filters and table

## SEO Pages
- New /admin/seo section with state landing pages
- SEO page generation and management
- State page content with dispensary/product counts

## Click Analytics
- Product click tracking infrastructure
- Click analytics dashboard

## Other Changes
- Consumer features scaffolding (alerts, deals, favorites)
- Health panel component
- Workers dashboard improvements
- Legacy DutchieAZ pages removed

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-07 22:48:21 -07:00
parent 38d3ea1408
commit 3bc0effa33
74 changed files with 12295 additions and 807 deletions

View File

@@ -340,6 +340,59 @@ The custom connection module at `src/dutchie-az/db/connection` is **DEPRECATED**
---
## PERFORMANCE REQUIREMENTS
**Database Queries:**
- NEVER write N+1 queries - always batch fetch related data before iterating
- NEVER run queries inside loops - batch them before the loop
- Avoid multiple queries when one JOIN or subquery works
- Dashboard/index pages should use MAX 5-10 queries total, not 50+
- Mentally trace query count - if a page would run 20+ queries, refactor
- Cache expensive aggregations (in-memory or Redis, 5-min TTL) instead of recalculating every request
- Use query logging during development to verify query count
**Before submitting route/controller code, verify:**
1. No queries inside `forEach`/`map`/`for` loops
2. All related data fetched in batches before iteration
3. Aggregations done in SQL (`COUNT`, `SUM`, `AVG`, `GROUP BY`), not in JS
4. **Would this cause a 503 under load? If unsure, simplify.**
**Examples of BAD patterns:**
```typescript
// BAD: N+1 query - runs a query for each store
const stores = await getStores();
for (const store of stores) {
store.products = await getProductsByStoreId(store.id); // N queries!
}
// BAD: Query inside map
const results = await Promise.all(
storeIds.map(id => pool.query('SELECT * FROM products WHERE store_id = $1', [id]))
);
```
**Examples of GOOD patterns:**
```typescript
// GOOD: Batch fetch all products, then group in JS
const stores = await getStores();
const storeIds = stores.map(s => s.id);
const allProducts = await pool.query(
'SELECT * FROM products WHERE store_id = ANY($1)', [storeIds]
);
const productsByStore = groupBy(allProducts.rows, 'store_id');
stores.forEach(s => s.products = productsByStore[s.id] || []);
// GOOD: Single query with JOIN
const result = await pool.query(`
SELECT s.*, COUNT(p.id) as product_count
FROM stores s
LEFT JOIN products p ON p.store_id = s.id
GROUP BY s.id
`);
```
---
## FORBIDDEN ACTIONS
1. **Deleting any data** (products, snapshots, images, logs, traces)

View File

@@ -0,0 +1,108 @@
-- Migration: Consumer verification and notification tracking
-- Adds email/SMS verification columns and notification history table
-- Add verification columns to users table
ALTER TABLE users
ADD COLUMN IF NOT EXISTS email_verified BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(64),
ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS phone_verified BOOLEAN DEFAULT false,
ADD COLUMN IF NOT EXISTS phone_verification_code VARCHAR(6),
ADD COLUMN IF NOT EXISTS phone_verification_sent_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS notification_preference VARCHAR(20) DEFAULT 'email'; -- email, sms, both
-- Add city/state to users (for notification filtering)
ALTER TABLE users
ADD COLUMN IF NOT EXISTS city VARCHAR(100),
ADD COLUMN IF NOT EXISTS state VARCHAR(50);
-- Create notification tracking table
CREATE TABLE IF NOT EXISTS consumer_notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
alert_id INTEGER, -- References findagram_alerts or findadispo saved search
alert_source VARCHAR(20), -- 'findagram' or 'findadispo'
notification_type VARCHAR(20) NOT NULL, -- 'email' or 'sms'
-- What triggered this notification
trigger_type VARCHAR(50) NOT NULL, -- 'price_drop', 'back_in_stock', 'product_on_special', 'deal_alert'
product_id INTEGER,
dispensary_id INTEGER,
-- Content
subject VARCHAR(255),
message_content TEXT,
-- Tracking
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
delivered_at TIMESTAMP,
opened_at TIMESTAMP,
clicked_at TIMESTAMP,
completed_at TIMESTAMP, -- When user has "seen" it or we mark it done
-- External IDs (for SMS gateway tracking)
external_message_id VARCHAR(100),
-- Status
status VARCHAR(20) DEFAULT 'pending', -- pending, sent, delivered, failed
error_message TEXT
);
-- Create findadispo_favorites table (mirroring findagram_favorites)
CREATE TABLE IF NOT EXISTS findadispo_favorites (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
dispensary_id INTEGER, -- References dispensaries table
-- Dispensary snapshot at time of save
dispensary_name VARCHAR(255),
dispensary_city VARCHAR(100),
dispensary_state VARCHAR(50),
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, dispensary_id)
);
-- Create findadispo_alerts table
CREATE TABLE IF NOT EXISTS findadispo_alerts (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
alert_type VARCHAR(50) NOT NULL, -- 'new_dispensary', 'deal_available'
-- Target
dispensary_id INTEGER,
city VARCHAR(100),
state VARCHAR(50),
-- Status
is_active BOOLEAN DEFAULT true,
last_triggered_at TIMESTAMP,
trigger_count INTEGER DEFAULT 0,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create findadispo_saved_searches table
CREATE TABLE IF NOT EXISTS findadispo_saved_searches (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
-- Search criteria
query TEXT,
city VARCHAR(100),
state VARCHAR(50),
min_rating DECIMAL(3, 2),
max_distance INTEGER,
amenities TEXT[], -- Array of amenity filters
-- Notification settings
notify_on_new_dispensary BOOLEAN DEFAULT false,
notify_on_deals BOOLEAN DEFAULT false,
-- Timestamps
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified);
CREATE INDEX IF NOT EXISTS idx_users_phone_verified ON users(phone_verified);
CREATE INDEX IF NOT EXISTS idx_users_city_state ON users(city, state);
CREATE INDEX IF NOT EXISTS idx_consumer_notifications_user_id ON consumer_notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_consumer_notifications_status ON consumer_notifications(status);
CREATE INDEX IF NOT EXISTS idx_consumer_notifications_sent_at ON consumer_notifications(sent_at);
CREATE INDEX IF NOT EXISTS idx_findadispo_favorites_user_id ON findadispo_favorites(user_id);
CREATE INDEX IF NOT EXISTS idx_findadispo_alerts_user_id ON findadispo_alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_findadispo_alerts_active ON findadispo_alerts(is_active) WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_findadispo_saved_searches_user_id ON findadispo_saved_searches(user_id);

View File

@@ -0,0 +1,50 @@
-- Migration: Product click event tracking for campaigns and analytics
-- Tracks user interactions with products across the platform
-- Create the product click events table
CREATE TABLE IF NOT EXISTS product_click_events (
id SERIAL PRIMARY KEY,
-- Core identifiers
product_id VARCHAR(100) NOT NULL, -- Internal product identifier
store_id VARCHAR(100), -- Store context (nullable)
brand_id VARCHAR(100), -- Brand context (nullable)
campaign_id INTEGER, -- Campaign context (nullable, FK to campaigns)
-- Action details
action VARCHAR(50) NOT NULL, -- 'view', 'open_store', 'open_product', 'compare', 'other'
source VARCHAR(100) NOT NULL, -- Page/component where click occurred
-- User context (optional - pulled from auth if available)
user_id INTEGER, -- Admin/user who triggered event
-- Request metadata
ip_address INET,
user_agent TEXT,
-- Timestamps
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for efficient querying
CREATE INDEX IF NOT EXISTS idx_product_click_events_product_id ON product_click_events(product_id);
CREATE INDEX IF NOT EXISTS idx_product_click_events_store_id ON product_click_events(store_id);
CREATE INDEX IF NOT EXISTS idx_product_click_events_brand_id ON product_click_events(brand_id);
CREATE INDEX IF NOT EXISTS idx_product_click_events_campaign_id ON product_click_events(campaign_id);
CREATE INDEX IF NOT EXISTS idx_product_click_events_action ON product_click_events(action);
CREATE INDEX IF NOT EXISTS idx_product_click_events_source ON product_click_events(source);
CREATE INDEX IF NOT EXISTS idx_product_click_events_occurred_at ON product_click_events(occurred_at);
CREATE INDEX IF NOT EXISTS idx_product_click_events_user_id ON product_click_events(user_id);
-- Composite index for campaign analytics
CREATE INDEX IF NOT EXISTS idx_product_click_events_campaign_product
ON product_click_events(campaign_id, product_id)
WHERE campaign_id IS NOT NULL;
-- Composite index for time-series queries
CREATE INDEX IF NOT EXISTS idx_product_click_events_time_action
ON product_click_events(occurred_at, action);
-- Add comment for documentation
COMMENT ON TABLE product_click_events IS 'Tracks product interactions across the CannaIQ platform for analytics and campaign measurement';

View File

@@ -0,0 +1,41 @@
-- Migration: Enhance click events for brand & campaign analytics
-- Adds event_type and page_type for better categorization
-- Add event_type column (default to 'product_click' for existing rows)
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS event_type VARCHAR(50) DEFAULT 'product_click';
-- Add page_type column for tracking which page the event originated from
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS page_type VARCHAR(100);
-- Add URL path for debugging/analysis
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS url_path TEXT;
-- Add device type (desktop, mobile, tablet)
ALTER TABLE product_click_events
ADD COLUMN IF NOT EXISTS device_type VARCHAR(20);
-- Create index on event_type for filtering
CREATE INDEX IF NOT EXISTS idx_product_click_events_event_type
ON product_click_events(event_type);
-- Create index on page_type for page-level analytics
CREATE INDEX IF NOT EXISTS idx_product_click_events_page_type
ON product_click_events(page_type);
-- Create composite index for brand analytics (brand_id + occurred_at)
CREATE INDEX IF NOT EXISTS idx_product_click_events_brand_time
ON product_click_events(brand_id, occurred_at)
WHERE brand_id IS NOT NULL;
-- Create composite index for store+brand analytics
CREATE INDEX IF NOT EXISTS idx_product_click_events_store_brand
ON product_click_events(store_id, brand_id)
WHERE store_id IS NOT NULL;
-- Add comment for documentation
COMMENT ON COLUMN product_click_events.event_type IS 'Type of event: product_click, page_view, etc.';
COMMENT ON COLUMN product_click_events.page_type IS 'Page where event occurred: StoreDetailPage, BrandsIntelligence, CampaignDetail, etc.';
COMMENT ON COLUMN product_click_events.device_type IS 'Device type: desktop, mobile, tablet';

View File

@@ -0,0 +1,22 @@
-- SEO Pages table for CannaiQ marketing content
-- All content stored here must be sanitized before insertion
CREATE TABLE IF NOT EXISTS seo_pages (
id SERIAL PRIMARY KEY,
slug VARCHAR(255) NOT NULL UNIQUE,
type VARCHAR(50) NOT NULL, -- state, brand, competitor, landing, blog
content JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft', -- draft, published, archived
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes for efficient lookup
CREATE INDEX IF NOT EXISTS idx_seo_pages_slug ON seo_pages(slug);
CREATE INDEX IF NOT EXISTS idx_seo_pages_type ON seo_pages(type);
CREATE INDEX IF NOT EXISTS idx_seo_pages_status ON seo_pages(status);
CREATE INDEX IF NOT EXISTS idx_seo_pages_type_status ON seo_pages(type, status);
-- Add comment explaining content requirements
COMMENT ON TABLE seo_pages IS 'SEO content for CannaiQ marketing pages. All content must use approved enterprise-safe phrasing.';
COMMENT ON COLUMN seo_pages.content IS 'JSON content with blocks structure. Must be sanitized via ContentValidator before insert.';

View File

@@ -41,6 +41,11 @@
"node": ">=18"
}
},
"node_modules/@ioredis/commands": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -104,6 +109,18 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@puppeteer/browsers": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz",
@@ -159,6 +176,15 @@
"@types/node": "*"
}
},
"node_modules/@types/bcryptjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
"dependencies": {
"bcryptjs": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -534,6 +560,14 @@
"node": ">= 10.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -630,6 +664,32 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/bullmq": {
"version": "5.65.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.65.1.tgz",
"integrity": "sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg==",
"dependencies": {
"cron-parser": "^4.9.0",
"ioredis": "^5.8.2",
"msgpackr": "^1.11.2",
"node-abort-controller": "^3.1.1",
"semver": "^7.5.4",
"tslib": "^2.0.0",
"uuid": "^11.1.0"
}
},
"node_modules/bullmq/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -778,6 +838,14 @@
"node": ">=0.10.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -913,6 +981,17 @@
}
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
@@ -1043,6 +1122,14 @@
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2096,6 +2183,50 @@
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/ioredis": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ioredis/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ioredis/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -2343,11 +2474,21 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -2386,6 +2527,14 @@
"node": ">=12"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": {
"node": ">=12"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -2614,6 +2763,35 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -2646,6 +2824,11 @@
"node": ">=10"
}
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="
},
"node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -2689,6 +2872,20 @@
}
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -3623,6 +3820,25 @@
"node": ">= 6"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4065,6 +4281,11 @@
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View File

@@ -8,8 +8,11 @@
"name": "dutchie-menus-backend",
"version": "1.5.1",
"dependencies": {
"@types/bcryptjs": "^3.0.0",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.65.1",
"cheerio": "^1.1.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
@@ -17,6 +20,7 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"ioredis": "^5.8.2",
"ipaddr.js": "^2.2.0",
"jsonwebtoken": "^9.0.2",
"minio": "^7.1.3",
@@ -482,6 +486,11 @@
"node": ">=18"
}
},
"node_modules/@ioredis/commands": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@@ -545,6 +554,78 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
]
},
"node_modules/@puppeteer/browsers": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.9.1.tgz",
@@ -600,6 +681,15 @@
"@types/node": "*"
}
},
"node_modules/@types/bcryptjs": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
"dependencies": {
"bcryptjs": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -975,6 +1065,14 @@
"node": ">= 10.0.0"
}
},
"node_modules/bcryptjs": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -1071,6 +1169,32 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"node_modules/bullmq": {
"version": "5.65.1",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.65.1.tgz",
"integrity": "sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg==",
"dependencies": {
"cron-parser": "^4.9.0",
"ioredis": "^5.8.2",
"msgpackr": "^1.11.2",
"node-abort-controller": "^3.1.1",
"semver": "^7.5.4",
"tslib": "^2.0.0",
"uuid": "^11.1.0"
}
},
"node_modules/bullmq/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1219,6 +1343,14 @@
"node": ">=0.10.0"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -1354,6 +1486,17 @@
}
}
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
"dependencies": {
"luxon": "^3.2.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
@@ -1484,6 +1627,14 @@
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2551,6 +2702,50 @@
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/ioredis": {
"version": "5.8.2",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ioredis/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ioredis/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -2798,11 +2993,21 @@
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@@ -2841,6 +3046,14 @@
"node": ">=12"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": {
"node": ">=12"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -3069,6 +3282,35 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/msgpackr": {
"version": "1.11.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz",
"integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -3101,6 +3343,11 @@
"node": ">=10"
}
},
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="
},
"node_modules/node-addon-api": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
@@ -3144,6 +3391,20 @@
}
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
@@ -4091,6 +4352,25 @@
"node": ">= 6"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -4533,6 +4813,11 @@
"node": ">= 10.x"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View File

@@ -5,8 +5,11 @@
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev:worker": "tsx watch src/cli.ts --worker",
"build": "tsc",
"start": "node dist/index.js",
"start:worker": "node dist/cli.js --worker",
"worker": "tsx src/cli.ts --worker",
"migrate": "tsx src/db/migrate.ts",
"seed": "tsx src/db/seed.ts",
"migrate:az": "tsx src/dutchie-az/db/migrate.ts",
@@ -19,8 +22,11 @@
"seed:dt:cities:bulk": "tsx src/scripts/seed-dt-cities-bulk.ts"
},
"dependencies": {
"@types/bcryptjs": "^3.0.0",
"axios": "^1.6.2",
"bcrypt": "^5.1.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.65.1",
"cheerio": "^1.1.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
@@ -28,6 +34,7 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"https-proxy-agent": "^7.0.2",
"ioredis": "^5.8.2",
"ipaddr.js": "^2.2.0",
"jsonwebtoken": "^9.0.2",
"minio": "^7.1.3",

View File

@@ -140,11 +140,72 @@ export function requireRole(...roles: string[]) {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
/**
* Optional auth middleware - attempts to authenticate but allows unauthenticated requests
*
* If a valid token is provided, sets req.user with the authenticated user.
* If no token or invalid token, continues without setting req.user.
*
* Use this for endpoints that work for both authenticated and anonymous users
* (e.g., product click tracking where we want user_id when available).
*/
export async function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
// No token provided - continue without auth
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return next();
}
const token = authHeader.substring(7);
// Try JWT first
const jwtUser = verifyToken(token);
if (jwtUser) {
req.user = jwtUser;
return next();
}
// If JWT fails, try API token
try {
const result = await pool.query(`
SELECT id, name, rate_limit, active, expires_at
FROM api_tokens
WHERE token = $1
`, [token]);
if (result.rows.length > 0) {
const apiToken = result.rows[0];
// Check if token is active and not expired
if (apiToken.active && (!apiToken.expires_at || new Date(apiToken.expires_at) >= new Date())) {
req.apiToken = {
id: apiToken.id,
name: apiToken.name,
rate_limit: apiToken.rate_limit
};
req.user = {
id: apiToken.id,
email: `api-token-${apiToken.id}@system`,
role: 'api'
};
}
}
} catch (error) {
// Silently ignore errors - optional auth should not fail the request
console.warn('[OptionalAuth] Error checking API token:', error);
}
next();
}

67
backend/src/cli.ts Normal file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env node
/**
* CLI Entrypoint for CannaIQ Backend
*
* Usage:
* npx tsx src/cli.ts # Start API server
* npx tsx src/cli.ts --worker # Start worker process
* npx tsx src/cli.ts --help # Show help
*
* Environment Variables:
* DATABASE_URL - PostgreSQL connection string (required)
* PORT - API server port (default: 3010)
* WORKER_ID - Worker instance identifier (auto-generated if not set)
*/
const args = process.argv.slice(2);
function showHelp() {
console.log(`
CannaIQ Backend CLI
Usage:
npx tsx src/cli.ts [options]
Options:
--worker Start as a job queue worker (processes crawl jobs)
--api Start as API server (default)
--help Show this help message
Environment Variables:
DATABASE_URL PostgreSQL connection string (required)
PORT API server port (default: 3010)
WORKER_ID Worker instance identifier (auto-generated)
Examples:
# Start API server on default port
DATABASE_URL="postgresql://..." npx tsx src/cli.ts
# Start worker process
DATABASE_URL="postgresql://..." npx tsx src/cli.ts --worker
# Start API on custom port
PORT=3015 DATABASE_URL="postgresql://..." npx tsx src/cli.ts --api
`);
process.exit(0);
}
async function main() {
if (args.includes('--help') || args.includes('-h')) {
showHelp();
}
if (args.includes('--worker')) {
console.log('[CLI] Starting worker process...');
const { startWorker } = await import('./dutchie-az/services/worker');
await startWorker();
} else {
// Default: start API server
console.log('[CLI] Starting API server...');
await import('./index');
}
}
main().catch((error) => {
console.error('[CLI] Fatal error:', error);
process.exit(1);
});

View File

@@ -1,8 +1,9 @@
/**
* Dutchie AZ API Routes
* Market Data API Routes
*
* Express routes for the Dutchie AZ data pipeline.
* Express routes for the cannabis market data pipeline.
* Provides API endpoints for stores, products, categories, and dashboard.
* Mounted at /api/markets (with legacy aliases at /api/az and /api/dutchie-az)
*/
import { Router, Request, Response } from 'express';
@@ -203,101 +204,113 @@ router.get('/stores/:id', async (req: Request, res: Response) => {
* GET /api/dutchie-az/stores/:id/summary
* Get store summary with product count, categories, and brands
* This is the main endpoint for the DispensaryDetail panel
* OPTIMIZED: Combined 5 sequential queries into 2 parallel queries
*/
router.get('/stores/:id/summary', async (req: Request, res: Response) => {
try {
const { id } = req.params;
const dispensaryId = parseInt(id, 10);
// Get dispensary info
const { rows: dispensaryRows } = await query(
`SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`,
[parseInt(id, 10)]
);
// Run all queries in parallel using Promise.all
const [dispensaryResult, aggregateResult] = await Promise.all([
// Query 1: Get dispensary info
query(
`SELECT ${DISPENSARY_COLUMNS} FROM dispensaries WHERE id = $1`,
[dispensaryId]
),
if (dispensaryRows.length === 0) {
// Query 2: All product aggregations in one query using CTEs
query(
`
WITH stock_counts AS (
SELECT
COUNT(*) as total_products,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count,
COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock_count,
COUNT(*) FILTER (WHERE stock_status = 'unknown') as unknown_count,
COUNT(*) FILTER (WHERE stock_status = 'missing_from_feed') as missing_count
FROM dutchie_products
WHERE dispensary_id = $1
),
category_agg AS (
SELECT jsonb_agg(
jsonb_build_object('type', type, 'subcategory', subcategory, 'product_count', cnt)
ORDER BY type, subcategory
) as categories
FROM (
SELECT type, subcategory, COUNT(*) as cnt
FROM dutchie_products
WHERE dispensary_id = $1 AND type IS NOT NULL
GROUP BY type, subcategory
) cat
),
brand_agg AS (
SELECT jsonb_agg(
jsonb_build_object('brand_name', brand_name, 'product_count', cnt)
ORDER BY cnt DESC
) as brands
FROM (
SELECT brand_name, COUNT(*) as cnt
FROM dutchie_products
WHERE dispensary_id = $1 AND brand_name IS NOT NULL
GROUP BY brand_name
) br
),
last_crawl AS (
SELECT
id, status, started_at, completed_at,
products_found, products_new, products_updated, error_message
FROM dispensary_crawl_jobs
WHERE dispensary_id = $1
ORDER BY created_at DESC
LIMIT 1
)
SELECT
sc.total_products, sc.in_stock_count, sc.out_of_stock_count, sc.unknown_count, sc.missing_count,
COALESCE(ca.categories, '[]'::jsonb) as categories,
COALESCE(ba.brands, '[]'::jsonb) as brands,
lc.id as last_crawl_id, lc.status as last_crawl_status,
lc.started_at as last_crawl_started, lc.completed_at as last_crawl_completed,
lc.products_found, lc.products_new, lc.products_updated, lc.error_message
FROM stock_counts sc
CROSS JOIN category_agg ca
CROSS JOIN brand_agg ba
LEFT JOIN last_crawl lc ON true
`,
[dispensaryId]
)
]);
if (dispensaryResult.rows.length === 0) {
return res.status(404).json({ error: 'Store not found' });
}
const dispensary = dispensaryRows[0];
// Get product counts by stock status
const { rows: countRows } = await query(
`
SELECT
COUNT(*) as total_products,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock_count,
COUNT(*) FILTER (WHERE stock_status = 'out_of_stock') as out_of_stock_count,
COUNT(*) FILTER (WHERE stock_status = 'unknown') as unknown_count,
COUNT(*) FILTER (WHERE stock_status = 'missing_from_feed') as missing_count
FROM dutchie_products
WHERE dispensary_id = $1
`,
[id]
);
// Get categories with counts for this store
const { rows: categories } = await query(
`
SELECT
type,
subcategory,
COUNT(*) as product_count
FROM dutchie_products
WHERE dispensary_id = $1 AND type IS NOT NULL
GROUP BY type, subcategory
ORDER BY type, subcategory
`,
[id]
);
// Get brands with counts for this store
const { rows: brands } = await query(
`
SELECT
brand_name,
COUNT(*) as product_count
FROM dutchie_products
WHERE dispensary_id = $1 AND brand_name IS NOT NULL
GROUP BY brand_name
ORDER BY product_count DESC
`,
[id]
);
// Get last crawl info
const { rows: lastCrawl } = await query(
`
SELECT
id,
status,
started_at,
completed_at,
products_found,
products_new,
products_updated,
error_message
FROM dispensary_crawl_jobs
WHERE dispensary_id = $1
ORDER BY created_at DESC
LIMIT 1
`,
[id]
);
const counts = countRows[0] || {};
const dispensary = dispensaryResult.rows[0];
const agg = aggregateResult.rows[0] || {};
const categories = agg.categories || [];
const brands = agg.brands || [];
res.json({
dispensary,
totalProducts: parseInt(counts.total_products || '0', 10),
inStockCount: parseInt(counts.in_stock_count || '0', 10),
outOfStockCount: parseInt(counts.out_of_stock_count || '0', 10),
unknownStockCount: parseInt(counts.unknown_count || '0', 10),
missingFromFeedCount: parseInt(counts.missing_count || '0', 10),
totalProducts: parseInt(agg.total_products || '0', 10),
inStockCount: parseInt(agg.in_stock_count || '0', 10),
outOfStockCount: parseInt(agg.out_of_stock_count || '0', 10),
unknownStockCount: parseInt(agg.unknown_count || '0', 10),
missingFromFeedCount: parseInt(agg.missing_count || '0', 10),
categories,
brands,
brandCount: brands.length,
categoryCount: categories.length,
lastCrawl: lastCrawl[0] || null,
lastCrawl: agg.last_crawl_id ? {
id: agg.last_crawl_id,
status: agg.last_crawl_status,
started_at: agg.last_crawl_started,
completed_at: agg.last_crawl_completed,
products_found: agg.products_found,
products_new: agg.products_new,
products_updated: agg.products_updated,
error_message: agg.error_message
} : null,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
@@ -1082,12 +1095,24 @@ router.post('/admin/crawl/:id', async (req: Request, res: Response) => {
import { bulkEnqueueJobs, getQueueStats as getJobQueueStats } from '../services/job-queue';
/**
* GET /api/dutchie-az/admin/dutchie-stores
* Get all Dutchie stores with their crawl status
* GET /api/markets/admin/crawlable-stores
* Get all crawlable stores with their crawl status
* OPTIMIZED: Replaced correlated subqueries with LEFT JOINs
*/
router.get('/admin/dutchie-stores', async (_req: Request, res: Response) => {
router.get('/admin/crawlable-stores', async (_req: Request, res: Response) => {
try {
const { rows } = await query(`
WITH product_counts AS (
SELECT dispensary_id, COUNT(*) as product_count
FROM dutchie_products
GROUP BY dispensary_id
),
snapshot_times AS (
SELECT p.dispensary_id, MAX(s.crawled_at) as last_snapshot_at
FROM dutchie_product_snapshots s
JOIN dutchie_products p ON s.dutchie_product_id = p.id
GROUP BY p.dispensary_id
)
SELECT
d.id,
d.name,
@@ -1100,18 +1125,11 @@ router.get('/admin/dutchie-stores', async (_req: Request, res: Response) => {
d.last_crawl_at,
d.consecutive_failures,
d.failed_at,
(
SELECT COUNT(*)
FROM dutchie_products
WHERE dispensary_id = d.id
) as product_count,
(
SELECT MAX(crawled_at)
FROM dutchie_product_snapshots s
JOIN dutchie_products p ON s.dutchie_product_id = p.id
WHERE p.dispensary_id = d.id
) as last_snapshot_at
COALESCE(pc.product_count, 0) as product_count,
st.last_snapshot_at
FROM dispensaries d
LEFT JOIN product_counts pc ON pc.dispensary_id = d.id
LEFT JOIN snapshot_times st ON st.dispensary_id = d.id
WHERE d.menu_type = 'dutchie'
AND d.state = 'AZ'
ORDER BY d.name
@@ -1150,9 +1168,14 @@ router.get('/admin/dutchie-stores', async (_req: Request, res: Response) => {
}
});
// Legacy alias (deprecated - use /admin/crawlable-stores)
router.get('/admin/dutchie-stores', (req: Request, res: Response) => {
res.redirect(307, '/api/markets/admin/crawlable-stores');
});
/**
* POST /api/dutchie-az/admin/crawl-all
* Enqueue crawl jobs for ALL ready Dutchie stores
* POST /api/markets/admin/crawl-all
* Enqueue crawl jobs for ALL ready stores
* This is a convenience endpoint to queue all stores without triggering the scheduler
*/
router.post('/admin/crawl-all', async (req: Request, res: Response) => {
@@ -1699,69 +1722,74 @@ import {
/**
* GET /api/dutchie-az/monitor/active-jobs
* Get all currently running jobs with real-time status including worker info
* OPTIMIZED: Run all queries in parallel
*/
router.get('/monitor/active-jobs', async (_req: Request, res: Response) => {
try {
// Get running jobs from job_run_logs (scheduled jobs like "enqueue all")
// Includes worker_name and run_role for named workforce display
const { rows: runningScheduledJobs } = await query<any>(`
SELECT
jrl.id,
jrl.schedule_id,
jrl.job_name,
jrl.status,
jrl.started_at,
jrl.items_processed,
jrl.items_succeeded,
jrl.items_failed,
jrl.metadata,
jrl.worker_name,
jrl.run_role,
js.description as job_description,
js.worker_name as schedule_worker_name,
js.worker_role as schedule_worker_role,
EXTRACT(EPOCH FROM (NOW() - jrl.started_at)) as duration_seconds
FROM job_run_logs jrl
LEFT JOIN job_schedules js ON jrl.schedule_id = js.id
WHERE jrl.status = 'running'
ORDER BY jrl.started_at DESC
`);
// Run all queries in parallel for better performance
const [scheduledJobsResult, crawlJobsResult, queueStats, activeWorkers] = await Promise.all([
// Query 1: Running scheduled jobs from job_run_logs
query<any>(`
SELECT
jrl.id,
jrl.schedule_id,
jrl.job_name,
jrl.status,
jrl.started_at,
jrl.items_processed,
jrl.items_succeeded,
jrl.items_failed,
jrl.metadata,
jrl.worker_name,
jrl.run_role,
js.description as job_description,
js.worker_name as schedule_worker_name,
js.worker_role as schedule_worker_role,
EXTRACT(EPOCH FROM (NOW() - jrl.started_at)) as duration_seconds
FROM job_run_logs jrl
LEFT JOIN job_schedules js ON jrl.schedule_id = js.id
WHERE jrl.status = 'running'
ORDER BY jrl.started_at DESC
`),
// Get running crawl jobs (individual store crawls with worker info)
// Includes enqueued_by_worker for tracking which named worker enqueued the job
const { rows: runningCrawlJobs } = await query<any>(`
SELECT
cj.id,
cj.job_type,
cj.dispensary_id,
d.name as dispensary_name,
d.city,
d.platform_dispensary_id,
cj.status,
cj.started_at,
cj.claimed_by as worker_id,
cj.worker_hostname,
cj.claimed_at,
cj.enqueued_by_worker,
cj.products_found,
cj.products_upserted,
cj.snapshots_created,
cj.current_page,
cj.total_pages,
cj.last_heartbeat_at,
cj.retry_count,
EXTRACT(EPOCH FROM (NOW() - cj.started_at)) as duration_seconds
FROM dispensary_crawl_jobs cj
LEFT JOIN dispensaries d ON cj.dispensary_id = d.id
WHERE cj.status = 'running'
ORDER BY cj.started_at DESC
`);
// Query 2: Running crawl jobs with dispensary info
query<any>(`
SELECT
cj.id,
cj.job_type,
cj.dispensary_id,
d.name as dispensary_name,
d.city,
d.platform_dispensary_id,
cj.status,
cj.started_at,
cj.claimed_by as worker_id,
cj.worker_hostname,
cj.claimed_at,
cj.enqueued_by_worker,
cj.products_found,
cj.products_upserted,
cj.snapshots_created,
cj.current_page,
cj.total_pages,
cj.last_heartbeat_at,
cj.retry_count,
EXTRACT(EPOCH FROM (NOW() - cj.started_at)) as duration_seconds
FROM dispensary_crawl_jobs cj
LEFT JOIN dispensaries d ON cj.dispensary_id = d.id
WHERE cj.status = 'running'
ORDER BY cj.started_at DESC
`),
// Get queue stats
const queueStats = await getQueueStats();
// Query 3: Queue stats
getQueueStats(),
// Get active workers
const activeWorkers = await getActiveWorkers();
// Query 4: Active workers
getActiveWorkers()
]);
const runningScheduledJobs = scheduledJobsResult.rows;
const runningCrawlJobs = crawlJobsResult.rows;
// Also get in-memory scrapers if any (from the legacy system)
let inMemoryScrapers: any[] = [];
@@ -2490,102 +2518,146 @@ router.get('/admin/crawl-traces/run/:runId', async (req: Request, res: Response)
/**
* GET /api/dutchie-az/scraper/overview
* Comprehensive scraper overview for the new dashboard
* OPTIMIZED: Combined 6 queries into 4 using CTEs (was 6)
*/
router.get('/scraper/overview', async (_req: Request, res: Response) => {
try {
// 1. Core KPI metrics
const { rows: kpiRows } = await query<any>(`
SELECT
-- Total products
(SELECT COUNT(*) FROM dutchie_products) AS total_products,
(SELECT COUNT(*) FROM dutchie_products WHERE stock_status = 'in_stock') AS in_stock_products,
-- Total dispensaries
(SELECT COUNT(*) FROM dispensaries WHERE menu_type = 'dutchie' AND state = 'AZ') AS total_dispensaries,
(SELECT COUNT(*) FROM dispensaries WHERE menu_type = 'dutchie' AND state = 'AZ' AND platform_dispensary_id IS NOT NULL) AS crawlable_dispensaries,
-- Visibility stats (24h)
(SELECT COUNT(*) FROM dutchie_products WHERE visibility_lost = true AND visibility_lost_at > NOW() - INTERVAL '24 hours') AS visibility_lost_24h,
(SELECT COUNT(*) FROM dutchie_products WHERE visibility_restored_at > NOW() - INTERVAL '24 hours') AS visibility_restored_24h,
(SELECT COUNT(*) FROM dutchie_products WHERE visibility_lost = true) AS total_visibility_lost,
-- Job stats (24h)
(SELECT COUNT(*) FROM job_run_logs WHERE status IN ('error', 'partial') AND created_at > NOW() - INTERVAL '24 hours') AS errors_24h,
(SELECT COUNT(*) FROM job_run_logs WHERE status = 'success' AND created_at > NOW() - INTERVAL '24 hours') AS successful_jobs_24h,
-- Active workers
(SELECT COUNT(*) FROM job_schedules WHERE enabled = true) AS active_workers
`);
// Run all queries in parallel using Promise.all for better performance
const [kpiResult, workerResult, timeSeriesResult, visibilityResult] = await Promise.all([
// Query 1: All KPI metrics in a single query using CTEs
query<any>(`
WITH product_stats AS (
SELECT
COUNT(*) AS total_products,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') AS in_stock_products,
COUNT(*) FILTER (WHERE visibility_lost = true AND visibility_lost_at > NOW() - INTERVAL '24 hours') AS visibility_lost_24h,
COUNT(*) FILTER (WHERE visibility_restored_at > NOW() - INTERVAL '24 hours') AS visibility_restored_24h,
COUNT(*) FILTER (WHERE visibility_lost = true) AS total_visibility_lost
FROM dutchie_products
),
dispensary_stats AS (
SELECT
COUNT(*) FILTER (WHERE menu_type = 'dutchie' AND state = 'AZ') AS total_dispensaries,
COUNT(*) FILTER (WHERE menu_type = 'dutchie' AND state = 'AZ' AND platform_dispensary_id IS NOT NULL) AS crawlable_dispensaries
FROM dispensaries
),
job_stats AS (
SELECT
COUNT(*) FILTER (WHERE status IN ('error', 'partial') AND created_at > NOW() - INTERVAL '24 hours') AS errors_24h,
COUNT(*) FILTER (WHERE status = 'success' AND created_at > NOW() - INTERVAL '24 hours') AS successful_jobs_24h
FROM job_run_logs
),
worker_stats AS (
SELECT COUNT(*) AS active_workers FROM job_schedules WHERE enabled = true
)
SELECT
ps.total_products, ps.in_stock_products, ps.visibility_lost_24h, ps.visibility_restored_24h, ps.total_visibility_lost,
ds.total_dispensaries, ds.crawlable_dispensaries,
js.errors_24h, js.successful_jobs_24h,
ws.active_workers
FROM product_stats ps, dispensary_stats ds, job_stats js, worker_stats ws
`),
// 2. Get active worker names
const { rows: workerRows } = await query<any>(`
SELECT worker_name, worker_role, enabled, last_status, last_run_at, next_run_at
FROM job_schedules
WHERE enabled = true
ORDER BY next_run_at ASC NULLS LAST
`);
// Query 2: Active worker details
query<any>(`
SELECT worker_name, worker_role, enabled, last_status, last_run_at, next_run_at
FROM job_schedules
WHERE enabled = true
ORDER BY next_run_at ASC NULLS LAST
`),
// 3. Scrape activity by hour (last 24h)
const { rows: activityRows } = await query<any>(`
SELECT
date_trunc('hour', started_at) AS hour,
COUNT(*) FILTER (WHERE status = 'success') AS successful,
COUNT(*) FILTER (WHERE status IN ('error', 'partial')) AS failed,
COUNT(*) AS total
FROM job_run_logs
WHERE started_at > NOW() - INTERVAL '24 hours'
GROUP BY date_trunc('hour', started_at)
ORDER BY hour ASC
`);
// Query 3: Time-series data (activity + growth + recent runs)
query<any>(`
WITH activity_by_hour AS (
SELECT
date_trunc('hour', started_at) AS hour,
COUNT(*) FILTER (WHERE status = 'success') AS successful,
COUNT(*) FILTER (WHERE status IN ('error', 'partial')) AS failed,
COUNT(*) AS total
FROM job_run_logs
WHERE started_at > NOW() - INTERVAL '24 hours'
GROUP BY date_trunc('hour', started_at)
),
product_growth AS (
SELECT
date_trunc('day', created_at) AS day,
COUNT(*) AS new_products
FROM dutchie_products
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY date_trunc('day', created_at)
),
recent_runs AS (
SELECT
jrl.id,
jrl.job_name,
jrl.status,
jrl.started_at,
jrl.completed_at,
jrl.items_processed,
jrl.items_succeeded,
jrl.items_failed,
jrl.metadata,
js.worker_name,
js.worker_role
FROM job_run_logs jrl
LEFT JOIN job_schedules js ON jrl.schedule_id = js.id
ORDER BY jrl.started_at DESC
LIMIT 20
)
SELECT
'activity' AS query_type,
jsonb_agg(jsonb_build_object('hour', hour, 'successful', successful, 'failed', failed, 'total', total) ORDER BY hour) AS data
FROM activity_by_hour
UNION ALL
SELECT
'growth' AS query_type,
jsonb_agg(jsonb_build_object('day', day, 'new_products', new_products) ORDER BY day) AS data
FROM product_growth
UNION ALL
SELECT
'runs' AS query_type,
jsonb_agg(jsonb_build_object(
'id', id, 'job_name', job_name, 'status', status, 'started_at', started_at,
'completed_at', completed_at, 'items_processed', items_processed,
'items_succeeded', items_succeeded, 'items_failed', items_failed,
'metadata', metadata, 'worker_name', worker_name, 'worker_role', worker_role
) ORDER BY started_at DESC) AS data
FROM recent_runs
`),
// 4. Product growth / coverage (last 7 days)
const { rows: growthRows } = await query<any>(`
SELECT
date_trunc('day', created_at) AS day,
COUNT(*) AS new_products
FROM dutchie_products
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY date_trunc('day', created_at)
ORDER BY day ASC
`);
// Query 4: Visibility changes by store
query<any>(`
SELECT
d.id AS dispensary_id,
d.name AS dispensary_name,
d.state,
COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') AS lost_24h,
COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') AS restored_24h,
MAX(dp.visibility_lost_at) AS latest_loss,
MAX(dp.visibility_restored_at) AS latest_restore
FROM dispensaries d
LEFT JOIN dutchie_products dp ON d.id = dp.dispensary_id
WHERE d.menu_type = 'dutchie'
GROUP BY d.id, d.name, d.state
HAVING COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') > 0
OR COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') > 0
ORDER BY lost_24h DESC, restored_24h DESC
LIMIT 15
`)
]);
// 5. Recent worker runs (last 20)
const { rows: recentRuns } = await query<any>(`
SELECT
jrl.id,
jrl.job_name,
jrl.status,
jrl.started_at,
jrl.completed_at,
jrl.items_processed,
jrl.items_succeeded,
jrl.items_failed,
jrl.metadata,
js.worker_name,
js.worker_role
FROM job_run_logs jrl
LEFT JOIN job_schedules js ON jrl.schedule_id = js.id
ORDER BY jrl.started_at DESC
LIMIT 20
`);
// Parse results
const kpi = kpiResult.rows[0] || {};
const workerRows = workerResult.rows;
const visibilityChanges = visibilityResult.rows;
// 6. Recent visibility changes by store
const { rows: visibilityChanges } = await query<any>(`
SELECT
d.id AS dispensary_id,
d.name AS dispensary_name,
d.state,
COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') AS lost_24h,
COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') AS restored_24h,
MAX(dp.visibility_lost_at) AS latest_loss,
MAX(dp.visibility_restored_at) AS latest_restore
FROM dispensaries d
LEFT JOIN dutchie_products dp ON d.id = dp.dispensary_id
WHERE d.menu_type = 'dutchie'
GROUP BY d.id, d.name, d.state
HAVING COUNT(dp.id) FILTER (WHERE dp.visibility_lost = true AND dp.visibility_lost_at > NOW() - INTERVAL '24 hours') > 0
OR COUNT(dp.id) FILTER (WHERE dp.visibility_restored_at > NOW() - INTERVAL '24 hours') > 0
ORDER BY lost_24h DESC, restored_24h DESC
LIMIT 15
`);
const kpi = kpiRows[0] || {};
// Parse time-series aggregated results
const timeSeriesMap = Object.fromEntries(
timeSeriesResult.rows.map((r: any) => [r.query_type, r.data || []])
);
const activityRows = timeSeriesMap['activity'] || [];
const growthRows = timeSeriesMap['growth'] || [];
const recentRuns = timeSeriesMap['runs'] || [];
res.json({
kpi: {

View File

@@ -23,10 +23,14 @@ app.use('/images', express.static(LOCAL_IMAGES_PATH));
const LOCAL_DOWNLOADS_PATH = process.env.LOCAL_DOWNLOADS_PATH || '/app/public/downloads';
app.use('/downloads', express.static(LOCAL_DOWNLOADS_PATH));
// Simple health check for load balancers/K8s probes
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Comprehensive health endpoints for monitoring (no auth required)
app.use('/api/health', healthRoutes);
// Endpoint to check server's outbound IP (for proxy whitelist setup)
app.get('/outbound-ip', async (req, res) => {
try {
@@ -62,6 +66,8 @@ import usersRoutes from './routes/users';
import staleProcessesRoutes from './routes/stale-processes';
import orchestratorAdminRoutes from './routes/orchestrator-admin';
import adminRoutes from './routes/admin';
import healthRoutes from './routes/health';
import workersRoutes from './routes/workers';
import { dutchieAZRouter, startScheduler as startDutchieAZScheduler, initializeDefaultSchedules } from './dutchie-az';
import { getPool } from './dutchie-az/db/connection';
import { createAnalyticsRouter } from './dutchie-az/routes/analytics';
@@ -77,6 +83,16 @@ import { createAnalyticsV2Router } from './routes/analytics-v2';
import { createDiscoveryRoutes } from './discovery';
import { createDutchieDiscoveryRoutes, promoteDiscoveryLocation } from './dutchie-az/discovery';
// Consumer API routes (findadispo.com, findagram.co)
import consumerAuthRoutes from './routes/consumer-auth';
import consumerFavoritesRoutes from './routes/consumer-favorites';
import consumerAlertsRoutes from './routes/consumer-alerts';
import consumerSavedSearchesRoutes from './routes/consumer-saved-searches';
import consumerDealsRoutes from './routes/consumer-deals';
import eventsRoutes from './routes/events';
import clickAnalyticsRoutes from './routes/click-analytics';
import seoRoutes from './routes/seo';
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
// These domains can access the API without authentication
app.use(markTrustedDomains);
@@ -96,6 +112,18 @@ app.use('/api/changes', changesRoutes);
app.use('/api/categories', categoriesRoutes);
app.use('/api/products', productsRoutes);
app.use('/api/campaigns', campaignsRoutes);
// Multi-state API routes - national analytics and cross-state comparisons (NO AUTH)
// IMPORTANT: Must be mounted BEFORE /api/analytics to avoid auth middleware blocking these routes
try {
const multiStateRoutes = createMultiStateRoutes(getPool());
app.use('/api', multiStateRoutes);
console.log('[MultiState] Routes registered at /api (analytics/national/*, states/*, etc.)');
} catch (error) {
console.warn('[MultiState] Failed to register routes (DB may not be configured):', error);
}
// Legacy click analytics routes (requires auth)
app.use('/api/analytics', analyticsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/proxies', proxiesRoutes);
@@ -112,16 +140,29 @@ app.use('/api/stale-processes', staleProcessesRoutes);
// Admin routes - operator actions (crawl triggers, health checks)
app.use('/api/admin', adminRoutes);
app.use('/api/admin/orchestrator', orchestratorAdminRoutes);
// Vendor-agnostic AZ data pipeline routes (new public surface)
// SEO orchestrator routes
app.use('/api/seo', seoRoutes);
// Provider-agnostic worker management routes (replaces /api/dutchie-az/admin/schedules)
app.use('/api/workers', workersRoutes);
// Monitor routes - aliased from workers for convenience
app.use('/api/monitor', workersRoutes);
console.log('[Workers] Routes registered at /api/workers and /api/monitor');
// Market data pipeline routes (provider-agnostic)
app.use('/api/markets', dutchieAZRouter);
// Legacy aliases (deprecated - remove after frontend migration)
app.use('/api/az', dutchieAZRouter);
// Legacy alias (kept temporarily for backward compatibility)
app.use('/api/dutchie-az', dutchieAZRouter);
// Phase 3: Analytics Dashboards - price trends, penetration, category growth, etc.
try {
const analyticsRouter = createAnalyticsRouter(getPool());
app.use('/api/markets/analytics', analyticsRouter);
// Legacy alias for backwards compatibility
app.use('/api/az/analytics', analyticsRouter);
console.log('[Analytics] Routes registered at /api/az/analytics');
console.log('[Analytics] Routes registered at /api/markets/analytics');
} catch (error) {
console.warn('[Analytics] Failed to register routes:', error);
}
@@ -139,15 +180,24 @@ try {
// Uses dutchie_az data pipeline with per-dispensary API key auth
app.use('/api/v1', publicApiRoutes);
// Multi-state API routes - national analytics and cross-state comparisons
// Phase 4: Multi-State Expansion
try {
const multiStateRoutes = createMultiStateRoutes(getPool());
app.use('/api', multiStateRoutes);
console.log('[MultiState] Routes registered');
} catch (error) {
console.warn('[MultiState] Failed to register routes (DB may not be configured):', error);
}
// Consumer API - findadispo.com and findagram.co user features
// Auth routes don't require authentication
app.use('/api/consumer/auth', consumerAuthRoutes);
// Protected consumer routes (favorites, alerts, saved searches)
app.use('/api/consumer/favorites', consumerFavoritesRoutes);
app.use('/api/consumer/alerts', consumerAlertsRoutes);
app.use('/api/consumer/saved-searches', consumerSavedSearchesRoutes);
// Deals endpoint - public, no auth required
app.use('/api/v1/deals', consumerDealsRoutes);
console.log('[Consumer] Routes registered at /api/consumer/*');
// Events API - product click tracking for analytics and campaigns
app.use('/api/events', eventsRoutes);
console.log('[Events] Routes registered at /api/events');
// Click Analytics API - brand and campaign engagement aggregations
app.use('/api/analytics/clicks', clickAnalyticsRoutes);
console.log('[ClickAnalytics] Routes registered at /api/analytics/clicks');
// States API routes - cannabis legalization status and targeting
try {

108
backend/src/lib/redis.ts Normal file
View File

@@ -0,0 +1,108 @@
/**
* Redis Connection Utility
*
* Provides a singleton Redis connection for:
* - BullMQ job queues (notifications, crawl jobs)
* - Rate limiting
* - Caching
* - Session storage
*/
import Redis from 'ioredis';
// Lazy-initialized Redis client singleton
let _redis: Redis | null = null;
/**
* Get Redis connection URL from environment
*/
function getRedisUrl(): string {
// Priority 1: Full Redis URL
if (process.env.REDIS_URL) {
return process.env.REDIS_URL;
}
// Priority 2: Individual env vars
const host = process.env.REDIS_HOST || 'localhost';
const port = process.env.REDIS_PORT || '6379';
const password = process.env.REDIS_PASSWORD;
if (password) {
return `redis://:${password}@${host}:${port}`;
}
return `redis://${host}:${port}`;
}
/**
* Get the Redis client (lazy singleton)
*/
export function getRedis(): Redis {
if (!_redis) {
const url = getRedisUrl();
console.log(`[Redis] Connecting to ${url.replace(/:[^:@]+@/, ':***@')}`);
_redis = new Redis(url, {
maxRetriesPerRequest: null, // Required for BullMQ
enableReadyCheck: false,
retryStrategy: (times: number) => {
if (times > 10) {
console.error('[Redis] Max retries reached, giving up');
return null;
}
const delay = Math.min(times * 100, 3000);
console.log(`[Redis] Retry attempt ${times}, waiting ${delay}ms`);
return delay;
},
});
_redis.on('connect', () => {
console.log('[Redis] Connected');
});
_redis.on('error', (err) => {
console.error('[Redis] Error:', err.message);
});
_redis.on('close', () => {
console.log('[Redis] Connection closed');
});
}
return _redis;
}
/**
* Check if Redis is available
*/
export async function isRedisAvailable(): Promise<boolean> {
try {
const redis = getRedis();
const pong = await redis.ping();
return pong === 'PONG';
} catch {
return false;
}
}
/**
* Close Redis connection
*/
export async function closeRedis(): Promise<void> {
if (_redis) {
await _redis.quit();
_redis = null;
console.log('[Redis] Disconnected');
}
}
/**
* Get BullMQ-compatible connection options
* BullMQ requires a specific connection format
*/
export function getBullMQConnection(): Redis {
return getRedis();
}
// Export types for convenience
export type { Redis };

View File

@@ -0,0 +1,27 @@
-- Migration: 052_create_seo_pages.sql
-- Purpose: Create seo_pages table for SEO orchestrator
CREATE TABLE IF NOT EXISTS seo_pages (
id SERIAL PRIMARY KEY,
type VARCHAR(50) NOT NULL CHECK (type IN ('state', 'brand', 'competitor_alternative', 'high_intent', 'insight_post')),
slug VARCHAR(255) NOT NULL UNIQUE,
page_key VARCHAR(255) NOT NULL,
primary_keyword VARCHAR(255),
status VARCHAR(50) DEFAULT 'pending_generation' CHECK (status IN ('draft', 'pending_generation', 'live', 'stale')),
data_source VARCHAR(100),
meta_title VARCHAR(255),
meta_description TEXT,
last_generated_at TIMESTAMPTZ,
last_reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_seo_pages_type ON seo_pages(type);
CREATE INDEX IF NOT EXISTS idx_seo_pages_status ON seo_pages(status);
CREATE INDEX IF NOT EXISTS idx_seo_pages_slug ON seo_pages(slug);
-- Record migration
INSERT INTO schema_migrations (version, name, applied_at)
VALUES (52, '052_create_seo_pages', NOW())
ON CONFLICT (version) DO NOTHING;

View File

@@ -0,0 +1,24 @@
-- Migration: 053_create_seo_page_contents.sql
-- Stores generated SEO content for each page
CREATE TABLE IF NOT EXISTS seo_page_contents (
id SERIAL PRIMARY KEY,
page_id INTEGER NOT NULL REFERENCES seo_pages(id) ON DELETE CASCADE,
blocks JSONB NOT NULL DEFAULT '[]',
meta_title VARCHAR(255),
meta_description TEXT,
h1 VARCHAR(255),
canonical_url VARCHAR(500),
og_title VARCHAR(255),
og_description TEXT,
og_image_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(page_id)
);
CREATE INDEX IF NOT EXISTS idx_seo_page_contents_page_id ON seo_page_contents(page_id);
INSERT INTO schema_migrations (version, name, applied_at)
VALUES (53, '053_create_seo_page_contents', NOW())
ON CONFLICT (version) DO NOTHING;

View File

@@ -447,5 +447,55 @@ export function createMultiStateRoutes(pool: Pool): Router {
}
});
// =========================================================================
// Health Check Endpoint
// =========================================================================
/**
* GET /api/health/analytics
* Health check for analytics subsystem
*/
router.get('/health/analytics', async (req: Request, res: Response) => {
try {
const startTime = Date.now();
// Check materialized view is accessible
const result = await pool.query(`
SELECT COUNT(*) as state_count,
MAX(refreshed_at) as last_refresh
FROM mv_state_metrics
`);
const dbLatency = Date.now() - startTime;
const stateCount = parseInt(result.rows[0]?.state_count || '0', 10);
const lastRefresh = result.rows[0]?.last_refresh;
// Check if data is stale (more than 24 hours old)
const isStale = lastRefresh
? Date.now() - new Date(lastRefresh).getTime() > 24 * 60 * 60 * 1000
: true;
res.json({
success: true,
status: isStale ? 'degraded' : 'healthy',
data: {
statesInCache: stateCount,
lastRefresh: lastRefresh || null,
isStale,
dbLatencyMs: dbLatency,
},
timestamp: new Date().toISOString(),
});
} catch (error: any) {
console.error('[MultiState] Health check failed:', error);
res.status(503).json({
success: false,
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString(),
});
}
});
return router;
}

View File

@@ -162,17 +162,17 @@ router.post('/:id/products', requireRole('superadmin', 'admin'), async (req, res
router.delete('/:id/products/:product_id', requireRole('superadmin', 'admin'), async (req, res) => {
try {
const { id, product_id } = req.params;
const result = await pool.query(`
DELETE FROM campaign_products
DELETE FROM campaign_products
WHERE campaign_id = $1 AND product_id = $2
RETURNING *
`, [id, product_id]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Product not in campaign' });
}
res.json({ message: 'Product removed from campaign' });
} catch (error) {
console.error('Error removing product from campaign:', error);
@@ -180,4 +180,139 @@ router.delete('/:id/products/:product_id', requireRole('superadmin', 'admin'), a
}
});
/**
* GET /api/campaigns/:id/click-summary
* Get product click event summary for a campaign
*
* Query params:
* - from: Start date (ISO)
* - to: End date (ISO)
*/
router.get('/:id/click-summary', async (req, res) => {
try {
const { id } = req.params;
const { from, to } = req.query;
// Check campaign exists
const campaignResult = await pool.query(
'SELECT id, name FROM campaigns WHERE id = $1',
[id]
);
if (campaignResult.rows.length === 0) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Build date filter conditions
const conditions: string[] = ['campaign_id = $1'];
const params: any[] = [id];
let paramIndex = 2;
if (from) {
conditions.push(`occurred_at >= $${paramIndex++}`);
params.push(new Date(from as string));
}
if (to) {
conditions.push(`occurred_at <= $${paramIndex++}`);
params.push(new Date(to as string));
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get overall stats
const statsResult = await pool.query(`
SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT product_id) as unique_products,
COUNT(DISTINCT store_id) as unique_stores,
COUNT(DISTINCT brand_id) as unique_brands,
COUNT(DISTINCT user_id) FILTER (WHERE user_id IS NOT NULL) as unique_users
FROM product_click_events
${whereClause}
`, params);
// Get clicks by action type
const byActionResult = await pool.query(`
SELECT
action,
COUNT(*) as count
FROM product_click_events
${whereClause}
GROUP BY action
ORDER BY count DESC
`, params);
// Get clicks by source
const bySourceResult = await pool.query(`
SELECT
source,
COUNT(*) as count
FROM product_click_events
${whereClause}
GROUP BY source
ORDER BY count DESC
`, params);
// Get top products (by click count)
const topProductsResult = await pool.query(`
SELECT
product_id,
COUNT(*) as click_count
FROM product_click_events
${whereClause}
GROUP BY product_id
ORDER BY click_count DESC
LIMIT 10
`, params);
// Get daily click counts (last 30 days by default)
const dailyParams = [...params];
let dailyWhereClause = whereClause;
if (!from) {
// Default to last 30 days
conditions.push(`occurred_at >= NOW() - INTERVAL '30 days'`);
dailyWhereClause = `WHERE ${conditions.join(' AND ')}`;
}
const dailyResult = await pool.query(`
SELECT
DATE(occurred_at) as date,
COUNT(*) as click_count
FROM product_click_events
${dailyWhereClause}
GROUP BY DATE(occurred_at)
ORDER BY date ASC
`, dailyParams);
res.json({
campaign: campaignResult.rows[0],
summary: {
totalClicks: parseInt(statsResult.rows[0].total_clicks, 10),
uniqueProducts: parseInt(statsResult.rows[0].unique_products, 10),
uniqueStores: parseInt(statsResult.rows[0].unique_stores, 10),
uniqueBrands: parseInt(statsResult.rows[0].unique_brands, 10),
uniqueUsers: parseInt(statsResult.rows[0].unique_users, 10)
},
byAction: byActionResult.rows.map(row => ({
action: row.action,
count: parseInt(row.count, 10)
})),
bySource: bySourceResult.rows.map(row => ({
source: row.source,
count: parseInt(row.count, 10)
})),
topProducts: topProductsResult.rows.map(row => ({
productId: row.product_id,
clickCount: parseInt(row.click_count, 10)
})),
daily: dailyResult.rows.map(row => ({
date: row.date,
clickCount: parseInt(row.click_count, 10)
}))
});
} catch (error: any) {
console.error('[Campaigns] Error fetching click summary:', error.message);
res.status(500).json({ error: 'Failed to fetch campaign click summary' });
}
});
export default router;

View File

@@ -0,0 +1,521 @@
/**
* Click Analytics API Routes
*
* Aggregates product click events by brand and campaign for analytics dashboards.
*
* Endpoints:
* GET /api/analytics/clicks/brands - Top brands by click engagement
* GET /api/analytics/clicks/campaigns - Top campaigns/specials by engagement
* GET /api/analytics/clicks/stores/:storeId/brands - Per-store brand engagement
* GET /api/analytics/clicks/summary - Overall click summary stats
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import { authMiddleware } from '../auth/middleware';
const router = Router();
// All click analytics endpoints require authentication
router.use(authMiddleware);
/**
* GET /api/analytics/clicks/brands
* Get top brands by click engagement
*
* Query params:
* - state: Filter by store state (e.g., 'AZ')
* - store_id: Filter by specific store
* - brand_id: Filter by specific brand
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/brands', async (req: Request, res: Response) => {
try {
const {
state,
store_id,
brand_id,
days = '30',
limit = '25'
} = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Build conditions and params
const conditions: string[] = [
'e.brand_id IS NOT NULL',
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
if (store_id) {
conditions.push(`e.store_id = $${paramIdx++}`);
params.push(store_id);
}
if (brand_id) {
conditions.push(`e.brand_id = $${paramIdx++}`);
params.push(brand_id);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Query for brand engagement
const result = await pool.query(`
SELECT
e.brand_id,
e.brand_id as brand_name,
COUNT(*) as clicks,
COUNT(DISTINCT e.product_id) as unique_products,
COUNT(DISTINCT e.store_id) as unique_stores,
MIN(e.occurred_at) as first_click_at,
MAX(e.occurred_at) as last_click_at
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY e.brand_id
ORDER BY clicks DESC
LIMIT ${limitNum}
`, params);
// Try to get actual brand names from products
const brandIds = result.rows.map(r => r.brand_id).filter(Boolean);
let brandNamesMap: Record<string, string> = {};
if (brandIds.length > 0) {
const brandNamesResult = await pool.query(`
SELECT DISTINCT brand_name
FROM dutchie_products
WHERE brand_name = ANY($1)
`, [brandIds]);
brandNamesResult.rows.forEach(r => {
brandNamesMap[r.brand_name] = r.brand_name;
});
}
const brands = result.rows.map(row => ({
brand_id: row.brand_id,
brand_name: brandNamesMap[row.brand_id] || row.brand_id,
clicks: parseInt(row.clicks, 10),
unique_products: parseInt(row.unique_products, 10),
unique_stores: parseInt(row.unique_stores, 10),
first_click_at: row.first_click_at,
last_click_at: row.last_click_at
}));
res.json({
filters: {
state: state || null,
store_id: store_id || null,
brand_id: brand_id || null,
days: daysNum
},
brands
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching brand analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch brand analytics' });
}
});
/**
* GET /api/analytics/clicks/products
* Get top products by click engagement
*
* Query params:
* - state: Filter by store state (e.g., 'AZ')
* - store_id: Filter by specific store
* - brand_id: Filter by specific brand
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/products', async (req: Request, res: Response) => {
try {
const {
state,
store_id,
brand_id,
days = '30',
limit = '25'
} = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Build conditions and params
const conditions: string[] = [
'e.product_id IS NOT NULL',
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
if (store_id) {
conditions.push(`e.store_id = $${paramIdx++}`);
params.push(store_id);
}
if (brand_id) {
conditions.push(`e.brand_id = $${paramIdx++}`);
params.push(brand_id);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Query for product engagement with product details from dutchie_products
const result = await pool.query(`
SELECT
e.product_id,
e.brand_id,
COUNT(*) as clicks,
COUNT(DISTINCT e.store_id) as unique_stores,
MIN(e.occurred_at) as first_click_at,
MAX(e.occurred_at) as last_click_at
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY e.product_id, e.brand_id
ORDER BY clicks DESC
LIMIT ${limitNum}
`, params);
// Try to get product details from dutchie_products
const productIds = result.rows.map(r => r.product_id).filter(Boolean);
let productDetailsMap: Record<string, { name: string; brand: string; type: string; subcategory: string }> = {};
if (productIds.length > 0) {
// Try to match by external_id or id
const productDetailsResult = await pool.query(`
SELECT
external_id,
id::text as product_id,
name,
brand_name,
type,
subcategory
FROM dutchie_products
WHERE external_id = ANY($1) OR id::text = ANY($1)
`, [productIds]);
productDetailsResult.rows.forEach(r => {
productDetailsMap[r.external_id] = {
name: r.name,
brand: r.brand_name,
type: r.type,
subcategory: r.subcategory
};
productDetailsMap[r.product_id] = {
name: r.name,
brand: r.brand_name,
type: r.type,
subcategory: r.subcategory
};
});
}
const products = result.rows.map(row => {
const details = productDetailsMap[row.product_id];
return {
product_id: row.product_id,
product_name: details?.name || `Product ${row.product_id}`,
brand_id: row.brand_id,
brand_name: details?.brand || row.brand_id || 'Unknown',
category: details?.type || null,
subcategory: details?.subcategory || null,
clicks: parseInt(row.clicks, 10),
unique_stores: parseInt(row.unique_stores, 10),
first_click_at: row.first_click_at,
last_click_at: row.last_click_at
};
});
res.json({
filters: {
state: state || null,
store_id: store_id || null,
brand_id: brand_id || null,
days: daysNum
},
products
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching product analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch product analytics' });
}
});
/**
* GET /api/analytics/clicks/campaigns
* Get top campaigns/specials by click engagement
*
* Query params:
* - state: Filter by store state
* - store_id: Filter by specific store
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/campaigns', async (req: Request, res: Response) => {
try {
const {
state,
store_id,
days = '30',
limit = '25'
} = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Build conditions
const conditions: string[] = [
'e.campaign_id IS NOT NULL',
`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`
];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
if (store_id) {
conditions.push(`e.store_id = $${paramIdx++}`);
params.push(store_id);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
// Query for campaign engagement with campaign details
const result = await pool.query(`
SELECT
e.campaign_id,
c.name as campaign_name,
c.slug as campaign_slug,
c.description as campaign_description,
c.active as is_active,
c.start_date,
c.end_date,
COUNT(*) as clicks,
COUNT(DISTINCT e.product_id) as unique_products,
COUNT(DISTINCT e.store_id) as unique_stores,
MIN(e.occurred_at) as first_event_at,
MAX(e.occurred_at) as last_event_at
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
LEFT JOIN campaigns c ON e.campaign_id = c.id
${whereClause}
GROUP BY e.campaign_id, c.name, c.slug, c.description, c.active, c.start_date, c.end_date
ORDER BY clicks DESC
LIMIT ${limitNum}
`, params);
const campaigns = result.rows.map(row => ({
campaign_id: row.campaign_id,
campaign_name: row.campaign_name || `Campaign ${row.campaign_id}`,
campaign_slug: row.campaign_slug,
campaign_description: row.campaign_description,
is_active: row.is_active,
start_date: row.start_date,
end_date: row.end_date,
clicks: parseInt(row.clicks, 10),
unique_products: parseInt(row.unique_products, 10),
unique_stores: parseInt(row.unique_stores, 10),
first_event_at: row.first_event_at,
last_event_at: row.last_event_at
}));
res.json({
filters: {
state: state || null,
store_id: store_id || null,
days: daysNum
},
campaigns
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching campaign analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch campaign analytics' });
}
});
/**
* GET /api/analytics/clicks/stores/:storeId/brands
* Get brand engagement for a specific store
*
* Query params:
* - days: Lookback window (default 30)
* - limit: Max results (default 25)
*/
router.get('/stores/:storeId/brands', async (req: Request, res: Response) => {
try {
const { storeId } = req.params;
const { days = '30', limit = '25' } = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const limitNum = Math.min(parseInt(limit as string, 10) || 25, 100);
// Get store info
const storeResult = await pool.query(
'SELECT id, name, dba_name, city, state FROM dispensaries WHERE id = $1',
[storeId]
);
if (storeResult.rows.length === 0) {
return res.status(404).json({ error: 'Store not found' });
}
const store = storeResult.rows[0];
// Query brand engagement for this store
const result = await pool.query(`
SELECT
e.brand_id,
COUNT(*) as clicks,
COUNT(DISTINCT e.product_id) as unique_products,
MIN(e.occurred_at) as first_click_at,
MAX(e.occurred_at) as last_click_at
FROM product_click_events e
WHERE e.store_id = $1
AND e.brand_id IS NOT NULL
AND e.occurred_at >= NOW() - INTERVAL '${daysNum} days'
GROUP BY e.brand_id
ORDER BY clicks DESC
LIMIT ${limitNum}
`, [storeId]);
const brands = result.rows.map(row => ({
brand_id: row.brand_id,
brand_name: row.brand_id, // Use brand_id as name for now
clicks: parseInt(row.clicks, 10),
unique_products: parseInt(row.unique_products, 10),
first_click_at: row.first_click_at,
last_click_at: row.last_click_at
}));
res.json({
store: {
id: store.id,
name: store.dba_name || store.name,
city: store.city,
state: store.state
},
filters: {
days: daysNum
},
brands
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching store brand analytics:', error.message);
res.status(500).json({ error: 'Failed to fetch store brand analytics' });
}
});
/**
* GET /api/analytics/clicks/summary
* Get overall click summary stats
*
* Query params:
* - state: Filter by store state
* - days: Lookback window (default 30)
*/
router.get('/summary', async (req: Request, res: Response) => {
try {
const { state, days = '30' } = req.query;
const daysNum = parseInt(days as string, 10) || 30;
const conditions: string[] = [`e.occurred_at >= NOW() - INTERVAL '${daysNum} days'`];
const params: any[] = [];
let paramIdx = 1;
if (state) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(state);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
// Get overall stats
const statsResult = await pool.query(`
SELECT
COUNT(*) as total_clicks,
COUNT(DISTINCT e.product_id) as unique_products,
COUNT(DISTINCT e.store_id) as unique_stores,
COUNT(DISTINCT e.brand_id) FILTER (WHERE e.brand_id IS NOT NULL) as unique_brands,
COUNT(*) FILTER (WHERE e.campaign_id IS NOT NULL) as campaign_clicks,
COUNT(DISTINCT e.campaign_id) FILTER (WHERE e.campaign_id IS NOT NULL) as unique_campaigns
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
`, params);
// Get clicks by action
const actionResult = await pool.query(`
SELECT
action,
COUNT(*) as count
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY action
ORDER BY count DESC
`, params);
// Get clicks by day (last 14 days for chart)
const dailyResult = await pool.query(`
SELECT
DATE(occurred_at) as date,
COUNT(*) as clicks
FROM product_click_events e
LEFT JOIN dispensaries d ON e.store_id::int = d.id
${whereClause}
GROUP BY DATE(occurred_at)
ORDER BY date DESC
LIMIT 14
`, params);
const stats = statsResult.rows[0];
res.json({
filters: {
state: state || null,
days: daysNum
},
summary: {
total_clicks: parseInt(stats.total_clicks, 10),
unique_products: parseInt(stats.unique_products, 10),
unique_stores: parseInt(stats.unique_stores, 10),
unique_brands: parseInt(stats.unique_brands, 10),
campaign_clicks: parseInt(stats.campaign_clicks, 10),
unique_campaigns: parseInt(stats.unique_campaigns, 10)
},
by_action: actionResult.rows.map(row => ({
action: row.action,
count: parseInt(row.count, 10)
})),
daily: dailyResult.rows.map(row => ({
date: row.date,
clicks: parseInt(row.clicks, 10)
})).reverse()
});
} catch (error: any) {
console.error('[ClickAnalytics] Error fetching click summary:', error.message);
res.status(500).json({ error: 'Failed to fetch click summary' });
}
});
export default router;

View File

@@ -0,0 +1,378 @@
/**
* Consumer Alerts API Routes
* Handles price alerts for findagram.co and deal alerts for findadispo.com
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import { authenticateConsumer } from './consumer-auth';
const router = Router();
// All routes require authentication
router.use(authenticateConsumer);
/**
* GET /api/consumer/alerts
* Get user's alerts
*/
router.get('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
const result = await pool.query(
`SELECT a.*,
p.name as product_name,
p.brand as product_brand,
p.image_url as product_image,
d.name as dispensary_name,
d.dba_name,
d.city as dispensary_city,
d.state as dispensary_state,
ls.price as current_price,
ls.stock_status as current_stock_status
FROM findagram_alerts a
LEFT JOIN store_products p ON a.product_id = p.id
LEFT JOIN dispensaries d ON a.dispensary_id = d.id
LEFT JOIN LATERAL (
SELECT price, stock_status FROM store_product_snapshots
WHERE product_id = a.product_id
ORDER BY crawled_at DESC LIMIT 1
) ls ON true
WHERE a.user_id = $1
ORDER BY a.created_at DESC`,
[userId]
);
res.json({
alerts: result.rows.map(row => ({
id: row.id,
alertType: row.alert_type,
productId: row.product_id,
productName: row.product_name,
productBrand: row.product_brand,
productImage: row.product_image,
dispensaryId: row.dispensary_id,
dispensaryName: row.dba_name || row.dispensary_name,
dispensaryCity: row.dispensary_city,
dispensaryState: row.dispensary_state,
brand: row.brand,
category: row.category,
targetPrice: row.target_price,
currentPrice: row.current_price,
currentStockStatus: row.current_stock_status,
isActive: row.is_active,
lastTriggeredAt: row.last_triggered_at,
triggerCount: row.trigger_count,
// Computed: is alert condition met?
isTriggered: row.alert_type === 'price_drop' && row.current_price && row.target_price &&
parseFloat(row.current_price) <= parseFloat(row.target_price),
createdAt: row.created_at
}))
});
} else if (domain === 'findadispo.com') {
const result = await pool.query(
`SELECT a.*,
d.name as dispensary_name,
d.dba_name,
d.city as dispensary_city,
d.state as dispensary_state
FROM findadispo_alerts a
LEFT JOIN dispensaries d ON a.dispensary_id = d.id
WHERE a.user_id = $1
ORDER BY a.created_at DESC`,
[userId]
);
res.json({
alerts: result.rows.map(row => ({
id: row.id,
alertType: row.alert_type,
dispensaryId: row.dispensary_id,
dispensaryName: row.dba_name || row.dispensary_name,
dispensaryCity: row.dispensary_city || row.city,
dispensaryState: row.dispensary_state || row.state,
city: row.city,
state: row.state,
isActive: row.is_active,
lastTriggeredAt: row.last_triggered_at,
triggerCount: row.trigger_count,
createdAt: row.created_at
}))
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Alerts] Get error:', error);
res.status(500).json({ error: 'Failed to get alerts' });
}
});
/**
* POST /api/consumer/alerts
* Create a new alert
*/
router.post('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
const { alertType, productId, dispensaryId, brand, category, targetPrice } = req.body;
// Validate alert type
const validTypes = ['price_drop', 'back_in_stock', 'product_on_special'];
if (!validTypes.includes(alertType)) {
return res.status(400).json({
error: 'Invalid alert type',
validTypes
});
}
// At least one target must be specified
if (!productId && !brand && !category) {
return res.status(400).json({
error: 'Must specify at least one of: productId, brand, or category'
});
}
// Price drop alerts require target price
if (alertType === 'price_drop' && !targetPrice) {
return res.status(400).json({
error: 'targetPrice required for price_drop alerts'
});
}
const result = await pool.query(
`INSERT INTO findagram_alerts
(user_id, alert_type, product_id, dispensary_id, brand, category, target_price, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
RETURNING id`,
[userId, alertType, productId || null, dispensaryId || null, brand || null, category || null, targetPrice || null]
);
res.status(201).json({
success: true,
alertId: result.rows[0].id,
message: 'Alert created'
});
} else if (domain === 'findadispo.com') {
const { alertType, dispensaryId, city, state } = req.body;
const validTypes = ['new_dispensary', 'deal_available'];
if (!validTypes.includes(alertType)) {
return res.status(400).json({
error: 'Invalid alert type',
validTypes
});
}
// Location alerts require city/state, dispensary alerts require dispensaryId
if (alertType === 'new_dispensary' && !city && !state) {
return res.status(400).json({
error: 'city or state required for new_dispensary alerts'
});
}
if (alertType === 'deal_available' && !dispensaryId && !city && !state) {
return res.status(400).json({
error: 'dispensaryId, city, or state required for deal_available alerts'
});
}
const result = await pool.query(
`INSERT INTO findadispo_alerts
(user_id, alert_type, dispensary_id, city, state, is_active)
VALUES ($1, $2, $3, $4, $5, true)
RETURNING id`,
[userId, alertType, dispensaryId || null, city || null, state || null]
);
res.status(201).json({
success: true,
alertId: result.rows[0].id,
message: 'Alert created'
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Alerts] Create error:', error);
res.status(500).json({ error: 'Failed to create alert' });
}
});
/**
* PUT /api/consumer/alerts/:id
* Update an alert
*/
router.put('/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const alertId = parseInt(req.params.id);
if (isNaN(alertId)) {
return res.status(400).json({ error: 'Invalid alert ID' });
}
const { isActive, targetPrice } = req.body;
const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts';
// Build update query dynamically
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (typeof isActive === 'boolean') {
updates.push(`is_active = $${paramIndex++}`);
values.push(isActive);
}
if (domain === 'findagram.co' && targetPrice !== undefined) {
updates.push(`target_price = $${paramIndex++}`);
values.push(targetPrice);
}
updates.push(`updated_at = NOW()`);
if (updates.length === 1) {
return res.status(400).json({ error: 'No valid fields to update' });
}
values.push(alertId, userId);
const result = await pool.query(
`UPDATE ${table} SET ${updates.join(', ')}
WHERE id = $${paramIndex++} AND user_id = $${paramIndex}
RETURNING id`,
values
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
res.json({ success: true, message: 'Alert updated' });
} catch (error) {
console.error('[Consumer Alerts] Update error:', error);
res.status(500).json({ error: 'Failed to update alert' });
}
});
/**
* DELETE /api/consumer/alerts/:id
* Delete an alert
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const alertId = parseInt(req.params.id);
if (isNaN(alertId)) {
return res.status(400).json({ error: 'Invalid alert ID' });
}
const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts';
const result = await pool.query(
`DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`,
[alertId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
res.json({ success: true, message: 'Alert deleted' });
} catch (error) {
console.error('[Consumer Alerts] Delete error:', error);
res.status(500).json({ error: 'Failed to delete alert' });
}
});
/**
* POST /api/consumer/alerts/:id/toggle
* Toggle alert active status
*/
router.post('/:id/toggle', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const alertId = parseInt(req.params.id);
if (isNaN(alertId)) {
return res.status(400).json({ error: 'Invalid alert ID' });
}
const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts';
const result = await pool.query(
`UPDATE ${table}
SET is_active = NOT is_active, updated_at = NOW()
WHERE id = $1 AND user_id = $2
RETURNING id, is_active`,
[alertId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Alert not found' });
}
res.json({
success: true,
isActive: result.rows[0].is_active,
message: result.rows[0].is_active ? 'Alert activated' : 'Alert deactivated'
});
} catch (error) {
console.error('[Consumer Alerts] Toggle error:', error);
res.status(500).json({ error: 'Failed to toggle alert' });
}
});
/**
* GET /api/consumer/alerts/stats
* Get alert statistics for user
*/
router.get('/stats', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const table = domain === 'findagram.co' ? 'findagram_alerts' : 'findadispo_alerts';
const result = await pool.query(
`SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE is_active = true) as active,
COUNT(*) FILTER (WHERE is_active = false) as inactive,
SUM(trigger_count) as total_triggers,
COUNT(*) FILTER (WHERE last_triggered_at > NOW() - INTERVAL '7 days') as triggered_this_week
FROM ${table}
WHERE user_id = $1`,
[userId]
);
const stats = result.rows[0];
res.json({
total: parseInt(stats.total) || 0,
active: parseInt(stats.active) || 0,
inactive: parseInt(stats.inactive) || 0,
totalTriggers: parseInt(stats.total_triggers) || 0,
triggeredThisWeek: parseInt(stats.triggered_this_week) || 0
});
} catch (error) {
console.error('[Consumer Alerts] Stats error:', error);
res.status(500).json({ error: 'Failed to get alert stats' });
}
});
export default router;

View File

@@ -0,0 +1,531 @@
/**
* Consumer Authentication Routes
* Handles registration, login, and verification for findadispo.com and findagram.co users
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
const router = Router();
// JWT secret - should be in env vars
const JWT_SECRET = process.env.JWT_SECRET || 'consumer-jwt-secret-change-in-production';
const JWT_EXPIRES_IN = '30d';
// SMS API configuration (sms.cannabrands.com)
const SMS_API_URL = process.env.SMS_API_URL || 'https://sms.cannabrands.com/api';
const SMS_API_KEY = process.env.SMS_API_KEY || '';
interface RegisterRequest {
firstName: string;
lastName: string;
email: string;
password: string;
phone?: string;
city?: string;
state?: string;
notificationPreference?: 'email' | 'sms' | 'both';
domain: 'findadispo.com' | 'findagram.co';
}
interface LoginRequest {
email: string;
password: string;
domain: 'findadispo.com' | 'findagram.co';
}
/**
* POST /api/consumer/auth/register
* Register a new consumer user
*/
router.post('/register', async (req: Request, res: Response) => {
try {
const {
firstName,
lastName,
email,
password,
phone,
city,
state,
notificationPreference = 'email',
domain
} = req.body as RegisterRequest;
// Validation
if (!firstName || !lastName || !email || !password || !domain) {
return res.status(400).json({
error: 'Missing required fields',
required: ['firstName', 'lastName', 'email', 'password', 'domain']
});
}
if (!['findadispo.com', 'findagram.co'].includes(domain)) {
return res.status(400).json({ error: 'Invalid domain' });
}
// Check if email already exists for this domain
const existingUser = await pool.query(
'SELECT id FROM users WHERE email = $1 AND domain = $2',
[email.toLowerCase(), domain]
);
if (existingUser.rows.length > 0) {
return res.status(409).json({ error: 'Email already registered' });
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Generate email verification token
const emailVerificationToken = crypto.randomBytes(32).toString('hex');
// Insert user
const userResult = await pool.query(
`INSERT INTO users (
email, password_hash, first_name, last_name, phone, city, state,
domain, role, notification_preference,
email_verification_token, email_verification_sent_at,
created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), NOW())
RETURNING id, email, first_name, last_name, phone, city, state, domain, notification_preference`,
[
email.toLowerCase(),
passwordHash,
firstName,
lastName,
phone || null,
city || null,
state || null,
domain,
'consumer',
notificationPreference,
emailVerificationToken
]
);
const user = userResult.rows[0];
// Create domain-specific profile
if (domain === 'findagram.co') {
await pool.query(
`INSERT INTO findagram_users (user_id, preferred_city, preferred_state, created_at)
VALUES ($1, $2, $3, NOW())`,
[user.id, city || null, state || null]
);
} else if (domain === 'findadispo.com') {
await pool.query(
`INSERT INTO findadispo_users (user_id, preferred_city, preferred_state, created_at)
VALUES ($1, $2, $3, NOW())`,
[user.id, city || null, state || null]
);
}
// Generate JWT token
const token = jwt.sign(
{ userId: user.id, email: user.email, domain },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
// TODO: Send email verification (integrate with SMTP service)
// For now, just log it
console.log(`[Consumer Auth] Verification email would be sent to ${email} with token ${emailVerificationToken}`);
// If phone provided, send SMS verification code
if (phone) {
const verificationCode = Math.floor(100000 + Math.random() * 900000).toString();
await pool.query(
'UPDATE users SET phone_verification_code = $1, phone_verification_sent_at = NOW() WHERE id = $2',
[verificationCode, user.id]
);
// TODO: Send SMS via sms.cannabrands.com
console.log(`[Consumer Auth] SMS verification code ${verificationCode} would be sent to ${phone}`);
}
res.status(201).json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
city: user.city,
state: user.state,
domain: user.domain,
notificationPreference: user.notification_preference,
emailVerified: false,
phoneVerified: false
},
token,
message: 'Registration successful. Please verify your email.'
});
} catch (error) {
console.error('[Consumer Auth] Registration error:', error);
res.status(500).json({ error: 'Registration failed' });
}
});
/**
* POST /api/consumer/auth/login
* Login a consumer user
*/
router.post('/login', async (req: Request, res: Response) => {
try {
const { email, password, domain } = req.body as LoginRequest;
if (!email || !password || !domain) {
return res.status(400).json({
error: 'Missing required fields',
required: ['email', 'password', 'domain']
});
}
// Find user
const userResult = await pool.query(
`SELECT id, email, password_hash, first_name, last_name, phone, city, state,
domain, notification_preference, email_verified, phone_verified
FROM users
WHERE email = $1 AND domain = $2 AND role = 'consumer'`,
[email.toLowerCase(), domain]
);
if (userResult.rows.length === 0) {
return res.status(401).json({ error: 'Invalid email or password' });
}
const user = userResult.rows[0];
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid email or password' });
}
// Generate JWT token
const token = jwt.sign(
{ userId: user.id, email: user.email, domain },
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
city: user.city,
state: user.state,
domain: user.domain,
notificationPreference: user.notification_preference,
emailVerified: user.email_verified,
phoneVerified: user.phone_verified
},
token
});
} catch (error) {
console.error('[Consumer Auth] Login error:', error);
res.status(500).json({ error: 'Login failed' });
}
});
/**
* POST /api/consumer/auth/verify-email
* Verify email with token
*/
router.post('/verify-email', async (req: Request, res: Response) => {
try {
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'Verification token required' });
}
const result = await pool.query(
`UPDATE users
SET email_verified = true,
email_verification_token = NULL,
updated_at = NOW()
WHERE email_verification_token = $1
RETURNING id, email, domain`,
[token]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'Invalid or expired verification token' });
}
res.json({
success: true,
message: 'Email verified successfully'
});
} catch (error) {
console.error('[Consumer Auth] Email verification error:', error);
res.status(500).json({ error: 'Verification failed' });
}
});
/**
* POST /api/consumer/auth/verify-phone
* Verify phone with SMS code
*/
router.post('/verify-phone', async (req: Request, res: Response) => {
try {
const { email, code, domain } = req.body;
if (!email || !code || !domain) {
return res.status(400).json({ error: 'Email, code, and domain required' });
}
// Check if code is valid and not expired (15 minute window)
const result = await pool.query(
`UPDATE users
SET phone_verified = true,
phone_verification_code = NULL,
updated_at = NOW()
WHERE email = $1
AND domain = $2
AND phone_verification_code = $3
AND phone_verification_sent_at > NOW() - INTERVAL '15 minutes'
RETURNING id, email`,
[email.toLowerCase(), domain, code]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'Invalid or expired verification code' });
}
res.json({
success: true,
message: 'Phone verified successfully'
});
} catch (error) {
console.error('[Consumer Auth] Phone verification error:', error);
res.status(500).json({ error: 'Verification failed' });
}
});
/**
* POST /api/consumer/auth/resend-email-verification
* Resend email verification
*/
router.post('/resend-email-verification', async (req: Request, res: Response) => {
try {
const { email, domain } = req.body;
if (!email || !domain) {
return res.status(400).json({ error: 'Email and domain required' });
}
const newToken = crypto.randomBytes(32).toString('hex');
const result = await pool.query(
`UPDATE users
SET email_verification_token = $1,
email_verification_sent_at = NOW()
WHERE email = $2 AND domain = $3 AND email_verified = false
RETURNING id, email`,
[newToken, email.toLowerCase(), domain]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'User not found or already verified' });
}
// TODO: Send email
console.log(`[Consumer Auth] Resending verification email to ${email} with token ${newToken}`);
res.json({
success: true,
message: 'Verification email sent'
});
} catch (error) {
console.error('[Consumer Auth] Resend verification error:', error);
res.status(500).json({ error: 'Failed to resend verification' });
}
});
/**
* POST /api/consumer/auth/resend-phone-verification
* Resend SMS verification code
*/
router.post('/resend-phone-verification', async (req: Request, res: Response) => {
try {
const { email, domain } = req.body;
if (!email || !domain) {
return res.status(400).json({ error: 'Email and domain required' });
}
const verificationCode = Math.floor(100000 + Math.random() * 900000).toString();
const result = await pool.query(
`UPDATE users
SET phone_verification_code = $1,
phone_verification_sent_at = NOW()
WHERE email = $2 AND domain = $3 AND phone IS NOT NULL AND phone_verified = false
RETURNING id, phone`,
[verificationCode, email.toLowerCase(), domain]
);
if (result.rows.length === 0) {
return res.status(400).json({ error: 'User not found, no phone, or already verified' });
}
// TODO: Send SMS via sms.cannabrands.com
console.log(`[Consumer Auth] Resending SMS code ${verificationCode} to ${result.rows[0].phone}`);
res.json({
success: true,
message: 'Verification code sent'
});
} catch (error) {
console.error('[Consumer Auth] Resend phone verification error:', error);
res.status(500).json({ error: 'Failed to resend verification' });
}
});
/**
* GET /api/consumer/auth/me
* Get current user profile (requires auth)
*/
router.get('/me', authenticateConsumer, async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const userResult = await pool.query(
`SELECT id, email, first_name, last_name, phone, city, state,
domain, notification_preference, email_verified, phone_verified
FROM users WHERE id = $1`,
[userId]
);
if (userResult.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
const user = userResult.rows[0];
// Get domain-specific profile
let profile = null;
if (domain === 'findagram.co') {
const profileResult = await pool.query(
'SELECT * FROM findagram_users WHERE user_id = $1',
[userId]
);
profile = profileResult.rows[0] || null;
} else if (domain === 'findadispo.com') {
const profileResult = await pool.query(
'SELECT * FROM findadispo_users WHERE user_id = $1',
[userId]
);
profile = profileResult.rows[0] || null;
}
res.json({
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
city: user.city,
state: user.state,
domain: user.domain,
notificationPreference: user.notification_preference,
emailVerified: user.email_verified,
phoneVerified: user.phone_verified
},
profile
});
} catch (error) {
console.error('[Consumer Auth] Get profile error:', error);
res.status(500).json({ error: 'Failed to get profile' });
}
});
/**
* PUT /api/consumer/auth/me
* Update current user profile
*/
router.put('/me', authenticateConsumer, async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const { firstName, lastName, phone, city, state, notificationPreference } = req.body;
// Update user table
await pool.query(
`UPDATE users SET
first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
phone = COALESCE($3, phone),
city = COALESCE($4, city),
state = COALESCE($5, state),
notification_preference = COALESCE($6, notification_preference),
updated_at = NOW()
WHERE id = $7`,
[firstName, lastName, phone, city, state, notificationPreference, userId]
);
// Update domain-specific profile
if (domain === 'findagram.co') {
await pool.query(
`UPDATE findagram_users SET
preferred_city = COALESCE($1, preferred_city),
preferred_state = COALESCE($2, preferred_state),
updated_at = NOW()
WHERE user_id = $3`,
[city, state, userId]
);
} else if (domain === 'findadispo.com') {
await pool.query(
`UPDATE findadispo_users SET
preferred_city = COALESCE($1, preferred_city),
preferred_state = COALESCE($2, preferred_state),
updated_at = NOW()
WHERE user_id = $3`,
[city, state, userId]
);
}
res.json({ success: true, message: 'Profile updated' });
} catch (error) {
console.error('[Consumer Auth] Update profile error:', error);
res.status(500).json({ error: 'Failed to update profile' });
}
});
/**
* Middleware to authenticate consumer requests
*/
export function authenticateConsumer(req: Request, res: Response, next: Function) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authorization required' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; email: string; domain: string };
(req as any).userId = decoded.userId;
(req as any).email = decoded.email;
(req as any).domain = decoded.domain;
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
export default router;

View File

@@ -0,0 +1,409 @@
/**
* Consumer Deals API Routes
* Provides pre-calculated deals and specials for findagram.co and findadispo.com
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
const router = Router();
/**
* GET /api/v1/deals
* Get products currently on special with calculated savings
*
* Query params:
* - state: Filter by state (default: AZ)
* - city: Filter by city
* - dispensaryId: Filter by specific dispensary
* - category: Filter by product category
* - brand: Filter by brand
* - minSavings: Minimum savings percentage (e.g., 20 for 20%)
* - limit: Results per page (default: 50)
* - offset: Pagination offset
* - sortBy: 'savings_percent' | 'savings_amount' | 'price' | 'newest' (default: savings_percent)
*/
router.get('/', async (req: Request, res: Response) => {
try {
const {
state = 'AZ',
city,
dispensaryId,
category,
brand,
minSavings,
limit = '50',
offset = '0',
sortBy = 'savings_percent'
} = req.query;
const params: any[] = [];
let paramIndex = 1;
const conditions: string[] = [
'sp.is_on_special = TRUE',
'sp.is_in_stock = TRUE'
];
// State filter
if (state) {
conditions.push(`d.state = $${paramIndex}`);
params.push(state);
paramIndex++;
}
// City filter
if (city) {
conditions.push(`d.city = $${paramIndex}`);
params.push(city);
paramIndex++;
}
// Dispensary filter
if (dispensaryId) {
conditions.push(`sp.dispensary_id = $${paramIndex}`);
params.push(parseInt(dispensaryId as string));
paramIndex++;
}
// Category filter
if (category) {
conditions.push(`sp.category_raw ILIKE $${paramIndex}`);
params.push(`%${category}%`);
paramIndex++;
}
// Brand filter
if (brand) {
conditions.push(`sp.brand_name_raw ILIKE $${paramIndex}`);
params.push(`%${brand}%`);
paramIndex++;
}
// Min savings filter
if (minSavings) {
conditions.push(`sp.discount_percent >= $${paramIndex}`);
params.push(parseFloat(minSavings as string));
paramIndex++;
}
// Build ORDER BY clause
let orderBy = 'sp.discount_percent DESC NULLS LAST';
switch (sortBy) {
case 'savings_amount':
orderBy = 'savings_amount DESC NULLS LAST';
break;
case 'price':
orderBy = 'COALESCE(sp.price_rec_special, sp.price_rec) ASC NULLS LAST';
break;
case 'newest':
orderBy = 'sp.updated_at DESC';
break;
case 'savings_percent':
default:
orderBy = 'sp.discount_percent DESC NULLS LAST';
}
// Add pagination
params.push(parseInt(limit as string));
params.push(parseInt(offset as string));
const query = `
SELECT
sp.id,
sp.dispensary_id,
sp.name_raw as product_name,
sp.brand_name_raw as brand,
sp.category_raw as category,
sp.subcategory_raw as subcategory,
-- Pricing
sp.price_rec as original_price,
sp.price_rec_special as sale_price,
sp.price_med as original_price_med,
sp.price_med_special as sale_price_med,
sp.is_on_special,
sp.special_name,
-- Calculated savings
sp.discount_percent as savings_percent,
CASE
WHEN sp.price_rec IS NOT NULL AND sp.price_rec_special IS NOT NULL
THEN sp.price_rec - sp.price_rec_special
ELSE NULL
END as savings_amount,
-- Product info
sp.thc_percent,
sp.cbd_percent,
sp.image_url,
sp.stock_status,
sp.provider_product_id,
-- Dispensary info
d.id as disp_id,
COALESCE(d.dba_name, d.name) as dispensary_name,
d.city as dispensary_city,
d.state as dispensary_state,
d.address as dispensary_address,
d.slug as dispensary_slug,
sp.updated_at as last_updated
FROM store_products sp
INNER JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE ${conditions.join(' AND ')}
ORDER BY ${orderBy}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const result = await pool.query(query, params);
// Get total count
const countQuery = `
SELECT COUNT(*) as total
FROM store_products sp
INNER JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE ${conditions.join(' AND ')}
`;
const countResult = await pool.query(countQuery, params.slice(0, -2));
const total = parseInt(countResult.rows[0]?.total || '0');
res.json({
deals: result.rows.map(row => ({
id: row.id,
productName: row.product_name,
brand: row.brand,
category: row.category,
subcategory: row.subcategory,
// Pricing with calculated savings
originalPrice: parseFloat(row.original_price) || null,
salePrice: parseFloat(row.sale_price) || null,
originalPriceMed: parseFloat(row.original_price_med) || null,
salePriceMed: parseFloat(row.sale_price_med) || null,
savingsAmount: parseFloat(row.savings_amount) || null,
savingsPercent: parseFloat(row.savings_percent) || null,
specialName: row.special_name,
// Product details
thcPercent: parseFloat(row.thc_percent) || null,
cbdPercent: parseFloat(row.cbd_percent) || null,
imageUrl: row.image_url,
stockStatus: row.stock_status,
productId: row.provider_product_id,
// Dispensary
dispensary: {
id: row.disp_id,
name: row.dispensary_name,
city: row.dispensary_city,
state: row.dispensary_state,
address: row.dispensary_address,
slug: row.dispensary_slug
},
lastUpdated: row.last_updated
})),
pagination: {
total,
limit: parseInt(limit as string),
offset: parseInt(offset as string),
hasMore: parseInt(offset as string) + result.rows.length < total
}
});
} catch (error) {
console.error('[Consumer Deals] Error:', error);
res.status(500).json({ error: 'Failed to get deals' });
}
});
/**
* GET /api/v1/deals/summary
* Get summary statistics about current deals
*/
router.get('/summary', async (req: Request, res: Response) => {
try {
const { state = 'AZ' } = req.query;
const result = await pool.query(`
SELECT
COUNT(*) as total_deals,
COUNT(DISTINCT sp.dispensary_id) as dispensaries_with_deals,
COUNT(DISTINCT sp.brand_name_raw) as brands_on_sale,
AVG(sp.discount_percent) as avg_discount,
MAX(sp.discount_percent) as max_discount,
MIN(sp.discount_percent) FILTER (WHERE sp.discount_percent > 0) as min_discount,
SUM(CASE WHEN sp.discount_percent >= 20 THEN 1 ELSE 0 END) as deals_over_20_pct,
SUM(CASE WHEN sp.discount_percent >= 30 THEN 1 ELSE 0 END) as deals_over_30_pct
FROM store_products sp
INNER JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.is_on_special = TRUE
AND sp.is_in_stock = TRUE
AND d.state = $1
`, [state]);
const stats = result.rows[0] || {};
// Get top categories with deals
const categoriesResult = await pool.query(`
SELECT sp.category_raw as category, COUNT(*) as deal_count
FROM store_products sp
INNER JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.is_on_special = TRUE
AND sp.is_in_stock = TRUE
AND d.state = $1
AND sp.category_raw IS NOT NULL
GROUP BY sp.category_raw
ORDER BY deal_count DESC
LIMIT 5
`, [state]);
res.json({
totalDeals: parseInt(stats.total_deals) || 0,
dispensariesWithDeals: parseInt(stats.dispensaries_with_deals) || 0,
brandsOnSale: parseInt(stats.brands_on_sale) || 0,
avgDiscount: parseFloat(stats.avg_discount)?.toFixed(1) || 0,
maxDiscount: parseFloat(stats.max_discount)?.toFixed(1) || 0,
minDiscount: parseFloat(stats.min_discount)?.toFixed(1) || 0,
dealsOver20Pct: parseInt(stats.deals_over_20_pct) || 0,
dealsOver30Pct: parseInt(stats.deals_over_30_pct) || 0,
topCategories: categoriesResult.rows.map(r => ({
category: r.category,
dealCount: parseInt(r.deal_count)
}))
});
} catch (error) {
console.error('[Consumer Deals] Summary error:', error);
res.status(500).json({ error: 'Failed to get deals summary' });
}
});
/**
* GET /api/v1/deals/by-dispensary/:dispensaryId
* Get all deals for a specific dispensary
*/
router.get('/by-dispensary/:dispensaryId', async (req: Request, res: Response) => {
try {
const dispensaryId = parseInt(req.params.dispensaryId);
const { limit = '100', offset = '0' } = req.query;
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
const result = await pool.query(`
SELECT
sp.id,
sp.name_raw as product_name,
sp.brand_name_raw as brand,
sp.category_raw as category,
sp.price_rec as original_price,
sp.price_rec_special as sale_price,
sp.discount_percent as savings_percent,
CASE
WHEN sp.price_rec IS NOT NULL AND sp.price_rec_special IS NOT NULL
THEN sp.price_rec - sp.price_rec_special
ELSE NULL
END as savings_amount,
sp.special_name,
sp.thc_percent,
sp.image_url,
sp.provider_product_id
FROM store_products sp
WHERE sp.dispensary_id = $1
AND sp.is_on_special = TRUE
AND sp.is_in_stock = TRUE
ORDER BY sp.discount_percent DESC NULLS LAST
LIMIT $2 OFFSET $3
`, [dispensaryId, parseInt(limit as string), parseInt(offset as string)]);
// Get dispensary info
const dispResult = await pool.query(`
SELECT id, COALESCE(dba_name, name) as name, city, state, address, slug
FROM dispensaries WHERE id = $1
`, [dispensaryId]);
const dispensary = dispResult.rows[0];
res.json({
dispensary: dispensary ? {
id: dispensary.id,
name: dispensary.name,
city: dispensary.city,
state: dispensary.state,
address: dispensary.address,
slug: dispensary.slug
} : null,
deals: result.rows.map(row => ({
id: row.id,
productName: row.product_name,
brand: row.brand,
category: row.category,
originalPrice: parseFloat(row.original_price) || null,
salePrice: parseFloat(row.sale_price) || null,
savingsAmount: parseFloat(row.savings_amount) || null,
savingsPercent: parseFloat(row.savings_percent) || null,
specialName: row.special_name,
thcPercent: parseFloat(row.thc_percent) || null,
imageUrl: row.image_url,
productId: row.provider_product_id
})),
totalDeals: result.rows.length
});
} catch (error) {
console.error('[Consumer Deals] By dispensary error:', error);
res.status(500).json({ error: 'Failed to get dispensary deals' });
}
});
/**
* GET /api/v1/deals/featured
* Get featured/best deals (highest savings, manually curated, etc.)
*/
router.get('/featured', async (req: Request, res: Response) => {
try {
const { state = 'AZ', limit = '10' } = req.query;
// Get top deals by savings percent, ensuring variety
const result = await pool.query(`
WITH ranked_deals AS (
SELECT
sp.*,
d.id as disp_id,
COALESCE(d.dba_name, d.name) as dispensary_name,
d.city as dispensary_city,
d.state as dispensary_state,
d.slug as dispensary_slug,
ROW_NUMBER() OVER (PARTITION BY sp.category_raw ORDER BY sp.discount_percent DESC) as category_rank
FROM store_products sp
INNER JOIN dispensaries d ON sp.dispensary_id = d.id
WHERE sp.is_on_special = TRUE
AND sp.is_in_stock = TRUE
AND d.state = $1
AND sp.discount_percent >= 15 -- Only significant discounts
)
SELECT *
FROM ranked_deals
WHERE category_rank <= 3 -- Top 3 per category for variety
ORDER BY discount_percent DESC
LIMIT $2
`, [state, parseInt(limit as string)]);
res.json({
featuredDeals: result.rows.map(row => ({
id: row.id,
productName: row.name_raw,
brand: row.brand_name_raw,
category: row.category_raw,
originalPrice: parseFloat(row.price_rec) || null,
salePrice: parseFloat(row.price_rec_special) || null,
savingsPercent: parseFloat(row.discount_percent) || null,
specialName: row.special_name,
thcPercent: parseFloat(row.thc_percent) || null,
imageUrl: row.image_url,
dispensary: {
id: row.disp_id,
name: row.dispensary_name,
city: row.dispensary_city,
state: row.dispensary_state,
slug: row.dispensary_slug
}
}))
});
} catch (error) {
console.error('[Consumer Deals] Featured error:', error);
res.status(500).json({ error: 'Failed to get featured deals' });
}
});
export default router;

View File

@@ -0,0 +1,369 @@
/**
* Consumer Favorites API Routes
* Handles product/dispensary favorites for findadispo.com and findagram.co users
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import { authenticateConsumer } from './consumer-auth';
const router = Router();
// All routes require authentication
router.use(authenticateConsumer);
/**
* GET /api/consumer/favorites
* Get user's favorites (products for findagram, dispensaries for findadispo)
*/
router.get('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
// Product favorites
const result = await pool.query(
`SELECT f.*,
p.name as current_name,
p.brand as current_brand,
ls.price as current_price,
ls.stock_status as current_stock_status,
d.name as dispensary_name,
d.city as dispensary_city,
d.state as dispensary_state
FROM findagram_favorites f
LEFT JOIN store_products p ON f.product_id = p.id
LEFT JOIN LATERAL (
SELECT price, stock_status
FROM store_product_snapshots
WHERE product_id = f.product_id
ORDER BY crawled_at DESC LIMIT 1
) ls ON true
LEFT JOIN dispensaries d ON f.dispensary_id = d.id
WHERE f.user_id = $1
ORDER BY f.created_at DESC`,
[userId]
);
res.json({
favorites: result.rows.map(row => ({
id: row.id,
productId: row.product_id,
dispensaryId: row.dispensary_id,
// Saved snapshot
savedName: row.product_name,
savedBrand: row.product_brand,
savedPrice: row.product_price,
imageUrl: row.product_image_url,
// Current data
currentName: row.current_name || row.product_name,
currentBrand: row.current_brand || row.product_brand,
currentPrice: row.current_price,
currentStockStatus: row.current_stock_status,
// Dispensary info
dispensaryName: row.dispensary_name,
dispensaryCity: row.dispensary_city,
dispensaryState: row.dispensary_state,
// Price change detection
priceChanged: row.current_price && row.product_price &&
parseFloat(row.current_price) !== parseFloat(row.product_price),
priceDrop: row.current_price && row.product_price &&
parseFloat(row.current_price) < parseFloat(row.product_price),
createdAt: row.created_at
}))
});
} else if (domain === 'findadispo.com') {
// Dispensary favorites
const result = await pool.query(
`SELECT f.*,
d.name as current_name,
d.dba_name,
d.address,
d.city as current_city,
d.state as current_state,
d.phone,
d.website,
d.menu_url,
d.hours,
d.latitude,
d.longitude,
(SELECT COUNT(*) FROM store_products WHERE dispensary_id = d.id AND stock_status = 'in_stock') as product_count
FROM findadispo_favorites f
LEFT JOIN dispensaries d ON f.dispensary_id = d.id
WHERE f.user_id = $1
ORDER BY f.created_at DESC`,
[userId]
);
res.json({
favorites: result.rows.map(row => ({
id: row.id,
dispensaryId: row.dispensary_id,
// Saved snapshot
savedName: row.dispensary_name,
savedCity: row.dispensary_city,
savedState: row.dispensary_state,
// Current data
currentName: row.dba_name || row.current_name || row.dispensary_name,
currentCity: row.current_city,
currentState: row.current_state,
address: row.address,
phone: row.phone,
website: row.website,
menuUrl: row.menu_url,
hours: row.hours,
latitude: row.latitude,
longitude: row.longitude,
productCount: parseInt(row.product_count) || 0,
createdAt: row.created_at
}))
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Favorites] Get error:', error);
res.status(500).json({ error: 'Failed to get favorites' });
}
});
/**
* POST /api/consumer/favorites
* Add a favorite
*/
router.post('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
const { productId, dispensaryId } = req.body;
if (!productId) {
return res.status(400).json({ error: 'productId required' });
}
// Get product details for snapshot
const productResult = await pool.query(
`SELECT p.name, p.brand, p.image_url,
ls.price
FROM store_products p
LEFT JOIN LATERAL (
SELECT price FROM store_product_snapshots
WHERE product_id = p.id ORDER BY crawled_at DESC LIMIT 1
) ls ON true
WHERE p.id = $1`,
[productId]
);
if (productResult.rows.length === 0) {
return res.status(404).json({ error: 'Product not found' });
}
const product = productResult.rows[0];
// Insert favorite (ON CONFLICT to handle duplicates)
const result = await pool.query(
`INSERT INTO findagram_favorites
(user_id, product_id, dispensary_id, product_name, product_brand, product_price, product_image_url)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (user_id, product_id) DO UPDATE SET
product_price = EXCLUDED.product_price,
created_at = findagram_favorites.created_at
RETURNING id`,
[userId, productId, dispensaryId, product.name, product.brand, product.price, product.image_url]
);
res.status(201).json({
success: true,
favoriteId: result.rows[0].id,
message: 'Product added to favorites'
});
} else if (domain === 'findadispo.com') {
const { dispensaryId } = req.body;
if (!dispensaryId) {
return res.status(400).json({ error: 'dispensaryId required' });
}
// Get dispensary details for snapshot
const dispResult = await pool.query(
'SELECT name, dba_name, city, state FROM dispensaries WHERE id = $1',
[dispensaryId]
);
if (dispResult.rows.length === 0) {
return res.status(404).json({ error: 'Dispensary not found' });
}
const dispensary = dispResult.rows[0];
// Insert favorite
const result = await pool.query(
`INSERT INTO findadispo_favorites
(user_id, dispensary_id, dispensary_name, dispensary_city, dispensary_state)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, dispensary_id) DO UPDATE SET
created_at = findadispo_favorites.created_at
RETURNING id`,
[userId, dispensaryId, dispensary.dba_name || dispensary.name, dispensary.city, dispensary.state]
);
res.status(201).json({
success: true,
favoriteId: result.rows[0].id,
message: 'Dispensary added to favorites'
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Favorites] Add error:', error);
res.status(500).json({ error: 'Failed to add favorite' });
}
});
/**
* DELETE /api/consumer/favorites/:id
* Remove a favorite by ID
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const favoriteId = parseInt(req.params.id);
if (isNaN(favoriteId)) {
return res.status(400).json({ error: 'Invalid favorite ID' });
}
const table = domain === 'findagram.co' ? 'findagram_favorites' : 'findadispo_favorites';
const result = await pool.query(
`DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`,
[favoriteId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Favorite not found' });
}
res.json({ success: true, message: 'Favorite removed' });
} catch (error) {
console.error('[Consumer Favorites] Delete error:', error);
res.status(500).json({ error: 'Failed to remove favorite' });
}
});
/**
* DELETE /api/consumer/favorites/product/:productId
* Remove a product favorite by product ID (findagram only)
*/
router.delete('/product/:productId', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const productId = parseInt(req.params.productId);
if (domain !== 'findagram.co') {
return res.status(400).json({ error: 'This endpoint is for findagram only' });
}
if (isNaN(productId)) {
return res.status(400).json({ error: 'Invalid product ID' });
}
const result = await pool.query(
'DELETE FROM findagram_favorites WHERE product_id = $1 AND user_id = $2 RETURNING id',
[productId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Favorite not found' });
}
res.json({ success: true, message: 'Product removed from favorites' });
} catch (error) {
console.error('[Consumer Favorites] Delete by product error:', error);
res.status(500).json({ error: 'Failed to remove favorite' });
}
});
/**
* DELETE /api/consumer/favorites/dispensary/:dispensaryId
* Remove a dispensary favorite by dispensary ID (findadispo only)
*/
router.delete('/dispensary/:dispensaryId', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const dispensaryId = parseInt(req.params.dispensaryId);
if (domain !== 'findadispo.com') {
return res.status(400).json({ error: 'This endpoint is for findadispo only' });
}
if (isNaN(dispensaryId)) {
return res.status(400).json({ error: 'Invalid dispensary ID' });
}
const result = await pool.query(
'DELETE FROM findadispo_favorites WHERE dispensary_id = $1 AND user_id = $2 RETURNING id',
[dispensaryId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Favorite not found' });
}
res.json({ success: true, message: 'Dispensary removed from favorites' });
} catch (error) {
console.error('[Consumer Favorites] Delete by dispensary error:', error);
res.status(500).json({ error: 'Failed to remove favorite' });
}
});
/**
* GET /api/consumer/favorites/check/:type/:id
* Check if an item is favorited
*/
router.get('/check/:type/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const { type, id } = req.params;
const itemId = parseInt(id);
if (isNaN(itemId)) {
return res.status(400).json({ error: 'Invalid ID' });
}
let isFavorited = false;
if (type === 'product' && domain === 'findagram.co') {
const result = await pool.query(
'SELECT id FROM findagram_favorites WHERE user_id = $1 AND product_id = $2',
[userId, itemId]
);
isFavorited = result.rows.length > 0;
} else if (type === 'dispensary' && domain === 'findadispo.com') {
const result = await pool.query(
'SELECT id FROM findadispo_favorites WHERE user_id = $1 AND dispensary_id = $2',
[userId, itemId]
);
isFavorited = result.rows.length > 0;
} else {
return res.status(400).json({ error: 'Invalid type for this domain' });
}
res.json({ isFavorited });
} catch (error) {
console.error('[Consumer Favorites] Check error:', error);
res.status(500).json({ error: 'Failed to check favorite status' });
}
});
export default router;

View File

@@ -0,0 +1,389 @@
/**
* Consumer Saved Searches API Routes
* Handles saved searches for findagram.co (products) and findadispo.com (dispensaries)
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import { authenticateConsumer } from './consumer-auth';
const router = Router();
// All routes require authentication
router.use(authenticateConsumer);
/**
* GET /api/consumer/saved-searches
* Get user's saved searches
*/
router.get('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
const result = await pool.query(
`SELECT * FROM findagram_saved_searches
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
res.json({
savedSearches: result.rows.map(row => ({
id: row.id,
name: row.name,
query: row.query,
category: row.category,
brand: row.brand,
strainType: row.strain_type,
minPrice: row.min_price,
maxPrice: row.max_price,
minThc: row.min_thc,
maxThc: row.max_thc,
city: row.city,
state: row.state,
notifyOnNew: row.notify_on_new,
notifyOnPriceDrop: row.notify_on_price_drop,
createdAt: row.created_at,
updatedAt: row.updated_at
}))
});
} else if (domain === 'findadispo.com') {
const result = await pool.query(
`SELECT * FROM findadispo_saved_searches
WHERE user_id = $1
ORDER BY created_at DESC`,
[userId]
);
res.json({
savedSearches: result.rows.map(row => ({
id: row.id,
name: row.name,
query: row.query,
city: row.city,
state: row.state,
minRating: row.min_rating,
maxDistance: row.max_distance,
amenities: row.amenities || [],
notifyOnNewDispensary: row.notify_on_new_dispensary,
notifyOnDeals: row.notify_on_deals,
createdAt: row.created_at,
updatedAt: row.updated_at
}))
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Saved Searches] Get error:', error);
res.status(500).json({ error: 'Failed to get saved searches' });
}
});
/**
* POST /api/consumer/saved-searches
* Create a saved search
*/
router.post('/', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
if (domain === 'findagram.co') {
const {
name,
query,
category,
brand,
strainType,
minPrice,
maxPrice,
minThc,
maxThc,
city,
state,
notifyOnNew = false,
notifyOnPriceDrop = false
} = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const result = await pool.query(
`INSERT INTO findagram_saved_searches
(user_id, name, query, category, brand, strain_type, min_price, max_price,
min_thc, max_thc, city, state, notify_on_new, notify_on_price_drop)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id`,
[
userId, name, query || null, category || null, brand || null,
strainType || null, minPrice || null, maxPrice || null,
minThc || null, maxThc || null, city || null, state || null,
notifyOnNew, notifyOnPriceDrop
]
);
res.status(201).json({
success: true,
savedSearchId: result.rows[0].id,
message: 'Search saved'
});
} else if (domain === 'findadispo.com') {
const {
name,
query,
city,
state,
minRating,
maxDistance,
amenities,
notifyOnNewDispensary = false,
notifyOnDeals = false
} = req.body;
if (!name) {
return res.status(400).json({ error: 'name is required' });
}
const result = await pool.query(
`INSERT INTO findadispo_saved_searches
(user_id, name, query, city, state, min_rating, max_distance, amenities,
notify_on_new_dispensary, notify_on_deals)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
[
userId, name, query || null, city || null, state || null,
minRating || null, maxDistance || null, amenities || null,
notifyOnNewDispensary, notifyOnDeals
]
);
res.status(201).json({
success: true,
savedSearchId: result.rows[0].id,
message: 'Search saved'
});
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Saved Searches] Create error:', error);
res.status(500).json({ error: 'Failed to save search' });
}
});
/**
* PUT /api/consumer/saved-searches/:id
* Update a saved search
*/
router.put('/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const searchId = parseInt(req.params.id);
if (isNaN(searchId)) {
return res.status(400).json({ error: 'Invalid search ID' });
}
if (domain === 'findagram.co') {
const {
name,
query,
category,
brand,
strainType,
minPrice,
maxPrice,
minThc,
maxThc,
city,
state,
notifyOnNew,
notifyOnPriceDrop
} = req.body;
const result = await pool.query(
`UPDATE findagram_saved_searches SET
name = COALESCE($1, name),
query = COALESCE($2, query),
category = COALESCE($3, category),
brand = COALESCE($4, brand),
strain_type = COALESCE($5, strain_type),
min_price = COALESCE($6, min_price),
max_price = COALESCE($7, max_price),
min_thc = COALESCE($8, min_thc),
max_thc = COALESCE($9, max_thc),
city = COALESCE($10, city),
state = COALESCE($11, state),
notify_on_new = COALESCE($12, notify_on_new),
notify_on_price_drop = COALESCE($13, notify_on_price_drop),
updated_at = NOW()
WHERE id = $14 AND user_id = $15
RETURNING id`,
[
name, query, category, brand, strainType, minPrice, maxPrice,
minThc, maxThc, city, state, notifyOnNew, notifyOnPriceDrop,
searchId, userId
]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
res.json({ success: true, message: 'Search updated' });
} else if (domain === 'findadispo.com') {
const {
name,
query,
city,
state,
minRating,
maxDistance,
amenities,
notifyOnNewDispensary,
notifyOnDeals
} = req.body;
const result = await pool.query(
`UPDATE findadispo_saved_searches SET
name = COALESCE($1, name),
query = COALESCE($2, query),
city = COALESCE($3, city),
state = COALESCE($4, state),
min_rating = COALESCE($5, min_rating),
max_distance = COALESCE($6, max_distance),
amenities = COALESCE($7, amenities),
notify_on_new_dispensary = COALESCE($8, notify_on_new_dispensary),
notify_on_deals = COALESCE($9, notify_on_deals),
updated_at = NOW()
WHERE id = $10 AND user_id = $11
RETURNING id`,
[
name, query, city, state, minRating, maxDistance,
amenities, notifyOnNewDispensary, notifyOnDeals,
searchId, userId
]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
res.json({ success: true, message: 'Search updated' });
} else {
res.status(400).json({ error: 'Invalid domain' });
}
} catch (error) {
console.error('[Consumer Saved Searches] Update error:', error);
res.status(500).json({ error: 'Failed to update search' });
}
});
/**
* DELETE /api/consumer/saved-searches/:id
* Delete a saved search
*/
router.delete('/:id', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const searchId = parseInt(req.params.id);
if (isNaN(searchId)) {
return res.status(400).json({ error: 'Invalid search ID' });
}
const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches';
const result = await pool.query(
`DELETE FROM ${table} WHERE id = $1 AND user_id = $2 RETURNING id`,
[searchId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
res.json({ success: true, message: 'Search deleted' });
} catch (error) {
console.error('[Consumer Saved Searches] Delete error:', error);
res.status(500).json({ error: 'Failed to delete search' });
}
});
/**
* POST /api/consumer/saved-searches/:id/run
* Execute a saved search and return results
* This builds the search URL/params that the frontend can use
*/
router.post('/:id/run', async (req: Request, res: Response) => {
try {
const userId = (req as any).userId;
const domain = (req as any).domain;
const searchId = parseInt(req.params.id);
if (isNaN(searchId)) {
return res.status(400).json({ error: 'Invalid search ID' });
}
const table = domain === 'findagram.co' ? 'findagram_saved_searches' : 'findadispo_saved_searches';
const result = await pool.query(
`SELECT * FROM ${table} WHERE id = $1 AND user_id = $2`,
[searchId, userId]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Saved search not found' });
}
const search = result.rows[0];
// Build search parameters for frontend to use
if (domain === 'findagram.co') {
const params: Record<string, any> = {};
if (search.query) params.q = search.query;
if (search.category) params.category = search.category;
if (search.brand) params.brand = search.brand;
if (search.strain_type) params.strainType = search.strain_type;
if (search.min_price) params.minPrice = search.min_price;
if (search.max_price) params.maxPrice = search.max_price;
if (search.min_thc) params.minThc = search.min_thc;
if (search.max_thc) params.maxThc = search.max_thc;
if (search.city) params.city = search.city;
if (search.state) params.state = search.state;
res.json({
searchParams: params,
searchUrl: `/products?${new URLSearchParams(params as any).toString()}`
});
} else {
const params: Record<string, any> = {};
if (search.query) params.q = search.query;
if (search.city) params.city = search.city;
if (search.state) params.state = search.state;
if (search.min_rating) params.minRating = search.min_rating;
if (search.max_distance) params.maxDistance = search.max_distance;
if (search.amenities?.length) params.amenities = search.amenities.join(',');
res.json({
searchParams: params,
searchUrl: `/?${new URLSearchParams(params as any).toString()}`
});
}
} catch (error) {
console.error('[Consumer Saved Searches] Run error:', error);
res.status(500).json({ error: 'Failed to run search' });
}
});
export default router;

View File

@@ -6,48 +6,57 @@ const router = Router();
router.use(authMiddleware);
// Get dashboard stats - uses consolidated dutchie-az DB
// OPTIMIZED: Combined 4 sequential queries into 1 using CTEs
router.get('/stats', async (req, res) => {
try {
// Store stats from dispensaries table in consolidated DB
const dispensariesResult = await azQuery(`
// All stats in a single query using CTEs
const result = await azQuery(`
WITH dispensary_stats AS (
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE menu_type IS NOT NULL AND menu_type != 'unknown') as active,
COUNT(*) FILTER (WHERE platform_dispensary_id IS NOT NULL) as with_platform_id,
COUNT(*) FILTER (WHERE menu_url IS NOT NULL) as with_menu_url,
MIN(last_crawled_at) as oldest_crawl,
MAX(last_crawled_at) as latest_crawl
FROM dispensaries
),
product_stats AS (
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock,
COUNT(*) FILTER (WHERE primary_image_url IS NOT NULL) as with_images,
COUNT(DISTINCT brand_name) FILTER (WHERE brand_name IS NOT NULL AND brand_name != '') as unique_brands,
COUNT(DISTINCT dispensary_id) as dispensaries_with_products,
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') as new_products_24h
FROM dutchie_products
)
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE menu_type IS NOT NULL AND menu_type != 'unknown') as active,
COUNT(*) FILTER (WHERE platform_dispensary_id IS NOT NULL) as with_platform_id,
COUNT(*) FILTER (WHERE menu_url IS NOT NULL) as with_menu_url,
MIN(last_crawled_at) as oldest_crawl,
MAX(last_crawled_at) as latest_crawl
FROM dispensaries
ds.total as store_total, ds.active as store_active,
ds.with_platform_id as store_with_platform_id, ds.with_menu_url as store_with_menu_url,
ds.oldest_crawl, ds.latest_crawl,
ps.total as product_total, ps.in_stock as product_in_stock,
ps.with_images as product_with_images, ps.unique_brands as product_unique_brands,
ps.dispensaries_with_products, ps.new_products_24h
FROM dispensary_stats ds, product_stats ps
`);
// Product stats from dutchie_products table
const productsResult = await azQuery(`
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE stock_status = 'in_stock') as in_stock,
COUNT(*) FILTER (WHERE primary_image_url IS NOT NULL) as with_images,
COUNT(DISTINCT brand_name) FILTER (WHERE brand_name IS NOT NULL AND brand_name != '') as unique_brands,
COUNT(DISTINCT dispensary_id) as dispensaries_with_products
FROM dutchie_products
`);
// Brand stats from dutchie_products
const brandResult = await azQuery(`
SELECT COUNT(DISTINCT brand_name) as total
FROM dutchie_products
WHERE brand_name IS NOT NULL AND brand_name != ''
`);
// Recent products added (last 24 hours)
const recentProductsResult = await azQuery(`
SELECT COUNT(*) as new_products_24h
FROM dutchie_products
WHERE created_at >= NOW() - INTERVAL '24 hours'
`);
// Combine results
const storeStats = dispensariesResult.rows[0];
const productStats = productsResult.rows[0];
const stats = result.rows[0] || {};
const storeStats = {
total: stats.store_total,
active: stats.store_active,
with_platform_id: stats.store_with_platform_id,
with_menu_url: stats.store_with_menu_url,
oldest_crawl: stats.oldest_crawl,
latest_crawl: stats.latest_crawl
};
const productStats = {
total: stats.product_total,
in_stock: stats.product_in_stock,
with_images: stats.product_with_images,
unique_brands: stats.product_unique_brands,
dispensaries_with_products: stats.dispensaries_with_products
};
res.json({
stores: {
@@ -66,11 +75,11 @@ router.get('/stats', async (req, res) => {
dispensaries_with_products: parseInt(productStats.dispensaries_with_products) || 0
},
brands: {
total: parseInt(brandResult.rows[0].total) || 0
total: parseInt(productStats.unique_brands) || 0 // Same as unique_brands from product stats
},
campaigns: { total: 0, active: 0 }, // Legacy - no longer used
clicks: { clicks_24h: 0 }, // Legacy - no longer used
recent: recentProductsResult.rows[0]
recent: { new_products_24h: parseInt(stats.new_products_24h) || 0 }
});
} catch (error) {
console.error('Error fetching dashboard stats:', error);

View File

@@ -0,0 +1,209 @@
/**
* Events API Routes - Product click tracking
*
* Tracks user interactions with products for analytics and campaign measurement.
*
* Endpoints:
* POST /api/events/product-click - Record a product click event
* GET /api/events/product-clicks - Get product click events (admin)
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
import { authMiddleware, optionalAuthMiddleware } from '../auth/middleware';
const router = Router();
// Valid action types
const VALID_ACTIONS = ['view', 'open_store', 'open_product', 'compare', 'other'];
interface ProductClickEventPayload {
product_id: string;
store_id?: string;
brand_id?: string;
campaign_id?: 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;
}
/**
* POST /api/events/product-click
* Record a product click event
*
* Fire-and-forget from frontend - returns quickly with minimal validation
*/
router.post('/product-click', optionalAuthMiddleware, async (req: Request, res: Response) => {
try {
const payload: ProductClickEventPayload = req.body;
// Basic validation
if (!payload.product_id || typeof payload.product_id !== 'string') {
return res.status(400).json({ status: 'error', error: 'product_id is required' });
}
if (!payload.action || !VALID_ACTIONS.includes(payload.action)) {
return res.status(400).json({
status: 'error',
error: `action must be one of: ${VALID_ACTIONS.join(', ')}`
});
}
if (!payload.source || typeof payload.source !== 'string') {
return res.status(400).json({ status: 'error', error: 'source is required' });
}
// Get user ID from auth context if available
const userId = (req as any).user?.id || null;
// Get IP and user agent from request
const ipAddress = req.ip || req.headers['x-forwarded-for'] || null;
const userAgent = req.headers['user-agent'] || null;
// Parse occurred_at or use current time
const occurredAt = payload.occurred_at ? new Date(payload.occurred_at) : new Date();
// Detect device type from user agent (simple heuristic)
let deviceType = 'desktop';
if (userAgent) {
const ua = userAgent.toLowerCase();
if (/mobile|android|iphone|ipad|ipod|blackberry|windows phone/i.test(ua)) {
deviceType = /ipad|tablet/i.test(ua) ? 'tablet' : 'mobile';
}
}
// 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)`,
[
payload.product_id,
payload.store_id || null,
payload.brand_id || null,
payload.campaign_id || null,
payload.action,
payload.source,
userId,
ipAddress,
userAgent,
occurredAt,
'product_click', // event_type
payload.page_type || null,
payload.url_path || null,
deviceType
]
);
res.json({ status: 'ok' });
} catch (error: any) {
console.error('[Events] Error recording product click:', error.message);
// Still return ok to not break frontend - events are non-critical
res.json({ status: 'ok' });
}
});
/**
* GET /api/events/product-clicks
* Get product click events (admin only)
*
* Query params:
* - product_id: Filter by product
* - store_id: Filter by store
* - brand_id: Filter by brand
* - campaign_id: Filter by campaign
* - action: Filter by action type
* - source: Filter by source
* - from: Start date (ISO)
* - to: End date (ISO)
* - limit: Max results (default 100)
* - offset: Pagination offset
*/
router.get('/product-clicks', authMiddleware, async (req: Request, res: Response) => {
try {
const {
product_id,
store_id,
brand_id,
campaign_id,
action,
source,
from,
to,
limit = '100',
offset = '0'
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (product_id) {
conditions.push(`product_id = $${paramIndex++}`);
params.push(product_id);
}
if (store_id) {
conditions.push(`store_id = $${paramIndex++}`);
params.push(store_id);
}
if (brand_id) {
conditions.push(`brand_id = $${paramIndex++}`);
params.push(brand_id);
}
if (campaign_id) {
conditions.push(`campaign_id = $${paramIndex++}`);
params.push(campaign_id);
}
if (action) {
conditions.push(`action = $${paramIndex++}`);
params.push(action);
}
if (source) {
conditions.push(`source = $${paramIndex++}`);
params.push(source);
}
if (from) {
conditions.push(`occurred_at >= $${paramIndex++}`);
params.push(new Date(from as string));
}
if (to) {
conditions.push(`occurred_at <= $${paramIndex++}`);
params.push(new Date(to as string));
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Get total count
const countResult = await pool.query(
`SELECT COUNT(*) as total FROM product_click_events ${whereClause}`,
params
);
const total = parseInt(countResult.rows[0].total, 10);
// Get events
params.push(parseInt(limit as string, 10));
params.push(parseInt(offset as string, 10));
const result = await pool.query(
`SELECT * FROM product_click_events
${whereClause}
ORDER BY occurred_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
params
);
res.json({
events: result.rows,
total,
limit: parseInt(limit as string, 10),
offset: parseInt(offset as string, 10)
});
} catch (error: any) {
console.error('[Events] Error fetching product clicks:', error.message);
res.status(500).json({ error: 'Failed to fetch product click events' });
}
});
export default router;

View File

@@ -0,0 +1,455 @@
/**
* Health Check Routes
*
* Comprehensive health endpoints for monitoring API, DB, Redis, Workers, Crawls, and Analytics.
*
* Endpoints:
* GET /api/health - Quick API health check
* GET /api/health/db - Postgres health
* GET /api/health/redis - Redis health
* GET /api/health/workers - Queue and worker status
* GET /api/health/crawls - Crawl activity summary
* GET /api/health/analytics - Analytics/aggregates status
* GET /api/health/full - Aggregated view of all subsystems
*/
import { Router, Request, Response } from 'express';
import { getPool, healthCheck as dbHealthCheck } from '../dutchie-az/db/connection';
import { getRedis } from '../lib/redis';
import * as fs from 'fs';
import * as path from 'path';
const router = Router();
// Read package version
let packageVersion = '1.0.0';
try {
const packagePath = path.join(__dirname, '../../package.json');
if (fs.existsSync(packagePath)) {
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
packageVersion = pkg.version || '1.0.0';
}
} catch {
// Ignore errors reading package.json
}
// Store server start time for uptime calculation
const serverStartTime = Date.now();
// Types
interface HealthStatus {
status: 'ok' | 'degraded' | 'error' | 'stale';
}
interface ApiHealth extends HealthStatus {
uptime: number;
timestamp: string;
version: string;
}
interface DbHealth extends HealthStatus {
connected: boolean;
latency_ms: number;
error?: string;
}
interface RedisHealth extends HealthStatus {
connected: boolean;
latency_ms: number;
error?: string;
}
interface QueueInfo {
name: string;
waiting: number;
active: number;
completed: number;
failed: number;
paused: boolean;
}
interface WorkerInfo {
id: string;
queue: string;
status: string;
last_heartbeat?: string;
}
interface WorkersHealth extends HealthStatus {
queues: QueueInfo[];
workers: WorkerInfo[];
}
interface CrawlsHealth extends HealthStatus {
last_run: string | null;
runs_last_24h: number;
stores_with_recent_crawl: number;
stores_total: number;
stale_stores: number;
}
interface AnalyticsHealth extends HealthStatus {
last_aggregate: string | null;
daily_runs_last_7d: number;
missing_days: number;
}
interface FullHealth extends HealthStatus {
api: ApiHealth;
db: DbHealth;
redis: RedisHealth;
workers: WorkersHealth;
crawls: CrawlsHealth;
analytics: AnalyticsHealth;
}
// ============================================================
// Helper Functions
// ============================================================
async function getApiHealth(): Promise<ApiHealth> {
return {
status: 'ok',
uptime: Math.floor((Date.now() - serverStartTime) / 1000),
timestamp: new Date().toISOString(),
version: packageVersion,
};
}
async function getDbHealth(): Promise<DbHealth> {
const start = Date.now();
try {
const pool = getPool();
await pool.query('SELECT 1');
return {
status: 'ok',
connected: true,
latency_ms: Date.now() - start,
};
} catch (err: any) {
return {
status: 'error',
connected: false,
latency_ms: Date.now() - start,
error: err.message || 'Database connection failed',
};
}
}
async function getRedisHealth(): Promise<RedisHealth> {
const start = Date.now();
// Check if Redis is configured
if (!process.env.REDIS_URL && !process.env.REDIS_HOST) {
return {
status: 'ok', // Redis is optional
connected: false,
latency_ms: 0,
error: 'Redis not configured',
};
}
try {
const redis = getRedis();
// Use a timeout to prevent hanging
const pingPromise = redis.ping();
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Redis ping timeout')), 3000)
);
await Promise.race([pingPromise, timeoutPromise]);
return {
status: 'ok',
connected: true,
latency_ms: Date.now() - start,
};
} catch (err: any) {
return {
status: 'degraded',
connected: false,
latency_ms: Date.now() - start,
error: err.message || 'Redis ping failed',
};
}
}
async function getWorkersHealth(): Promise<WorkersHealth> {
try {
const pool = getPool();
// Get queue stats from v_queue_stats view or equivalent
const queueStatsResult = await pool.query(`
SELECT
job_type as name,
COUNT(*) FILTER (WHERE status = 'pending') as waiting,
COUNT(*) FILTER (WHERE status = 'running') as active,
COUNT(*) FILTER (WHERE status = 'success') as completed,
COUNT(*) FILTER (WHERE status IN ('error', 'failed')) as failed,
false as paused
FROM dispensary_crawl_jobs
WHERE created_at > NOW() - INTERVAL '7 days'
GROUP BY job_type
`);
const queues: QueueInfo[] = queueStatsResult.rows.map((row: any) => ({
name: row.name || 'unknown',
waiting: parseInt(row.waiting) || 0,
active: parseInt(row.active) || 0,
completed: parseInt(row.completed) || 0,
failed: parseInt(row.failed) || 0,
paused: row.paused || false,
}));
// Get active workers from job_schedules or active heartbeats
const workersResult = await pool.query(`
SELECT
COALESCE(job_config->>'worker_name', job_name) as id,
job_name as queue,
CASE WHEN enabled THEN 'connected' ELSE 'disconnected' END as status,
last_run_at as last_heartbeat
FROM job_schedules
WHERE enabled = true
ORDER BY last_run_at DESC NULLS LAST
LIMIT 20
`);
const workers: WorkerInfo[] = workersResult.rows.map((row: any) => ({
id: row.id,
queue: row.queue,
status: row.status,
last_heartbeat: row.last_heartbeat?.toISOString() || undefined,
}));
// Determine overall status
const hasActiveWorkers = workers.length > 0;
const hasFailedJobs = queues.some((q) => q.failed > 0);
const hasStuckJobs = queues.some((q) => q.active > 5); // Arbitrary threshold
let status: 'ok' | 'degraded' | 'error' = 'ok';
if (!hasActiveWorkers) {
status = 'degraded';
} else if (hasFailedJobs || hasStuckJobs) {
status = 'degraded';
}
return {
status,
queues,
workers,
};
} catch (err: any) {
return {
status: 'error',
queues: [],
workers: [],
};
}
}
async function getCrawlsHealth(): Promise<CrawlsHealth> {
try {
const pool = getPool();
// Get crawl statistics
const statsResult = await pool.query(`
SELECT
(SELECT MAX(completed_at) FROM dispensary_crawl_jobs WHERE status = 'success') as last_run,
(SELECT COUNT(*) FROM dispensary_crawl_jobs WHERE status = 'success' AND completed_at > NOW() - INTERVAL '24 hours') as runs_24h,
(SELECT COUNT(*) FROM dispensaries WHERE last_crawl_at > NOW() - INTERVAL '24 hours') as stores_recent,
(SELECT COUNT(*) FROM dispensaries WHERE menu_type IS NOT NULL AND platform_dispensary_id IS NOT NULL) as stores_total,
(SELECT COUNT(*) FROM dispensaries WHERE menu_type = 'dutchie' AND platform_dispensary_id IS NOT NULL AND (last_crawl_at IS NULL OR last_crawl_at < NOW() - INTERVAL '24 hours')) as stores_stale
`);
const stats = statsResult.rows[0] || {};
const storesTotal = parseInt(stats.stores_total) || 0;
const storesRecent = parseInt(stats.stores_recent) || 0;
const staleStores = parseInt(stats.stores_stale) || 0;
// Calculate freshness percentage
const freshPercent = storesTotal > 0 ? (storesRecent / storesTotal) * 100 : 0;
let status: 'ok' | 'degraded' | 'stale' | 'error' = 'ok';
if (freshPercent >= 90) {
status = 'ok';
} else if (freshPercent >= 50) {
status = 'degraded';
} else {
status = 'stale';
}
return {
status,
last_run: stats.last_run?.toISOString() || null,
runs_last_24h: parseInt(stats.runs_24h) || 0,
stores_with_recent_crawl: storesRecent,
stores_total: storesTotal,
stale_stores: staleStores,
};
} catch (err: any) {
return {
status: 'error',
last_run: null,
runs_last_24h: 0,
stores_with_recent_crawl: 0,
stores_total: 0,
stale_stores: 0,
};
}
}
async function getAnalyticsHealth(): Promise<AnalyticsHealth> {
try {
const pool = getPool();
// Check analytics/aggregate job runs
const statsResult = await pool.query(`
SELECT
(SELECT MAX(completed_at) FROM job_run_logs WHERE job_name LIKE '%analytics%' AND status = 'success') as last_aggregate,
(SELECT COUNT(DISTINCT DATE(started_at)) FROM job_run_logs WHERE job_name LIKE '%analytics%' AND status = 'success' AND started_at > NOW() - INTERVAL '7 days') as runs_7d
`);
const stats = statsResult.rows[0] || {};
const runsLast7d = parseInt(stats.runs_7d) || 0;
const missingDays = Math.max(0, 7 - runsLast7d);
let status: 'ok' | 'degraded' | 'stale' | 'error' = 'ok';
if (missingDays === 0) {
status = 'ok';
} else if (missingDays <= 2) {
status = 'degraded';
} else {
status = 'stale';
}
return {
status,
last_aggregate: stats.last_aggregate?.toISOString() || null,
daily_runs_last_7d: runsLast7d,
missing_days: missingDays,
};
} catch (err: any) {
return {
status: 'error',
last_aggregate: null,
daily_runs_last_7d: 0,
missing_days: 7,
};
}
}
function determineOverallStatus(
api: ApiHealth,
db: DbHealth,
redis: RedisHealth,
workers: WorkersHealth,
crawls: CrawlsHealth,
analytics: AnalyticsHealth
): 'ok' | 'degraded' | 'error' {
const statuses = [api.status, db.status, redis.status, workers.status, crawls.status, analytics.status];
if (statuses.includes('error')) {
return 'error';
}
if (statuses.includes('degraded') || statuses.includes('stale')) {
return 'degraded';
}
return 'ok';
}
// ============================================================
// Routes
// ============================================================
/**
* GET /api/health - Quick API health check (no auth required)
*/
router.get('/', async (_req: Request, res: Response) => {
const health = await getApiHealth();
res.json(health);
});
/**
* GET /api/health/db - Postgres health
*/
router.get('/db', async (_req: Request, res: Response) => {
const health = await getDbHealth();
const statusCode = health.status === 'ok' ? 200 : 503;
res.status(statusCode).json(health);
});
/**
* GET /api/health/redis - Redis health
*/
router.get('/redis', async (_req: Request, res: Response) => {
const health = await getRedisHealth();
const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
});
/**
* GET /api/health/workers - Queue and worker status
*/
router.get('/workers', async (_req: Request, res: Response) => {
const health = await getWorkersHealth();
const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
});
/**
* GET /api/health/crawls - Crawl activity summary
*/
router.get('/crawls', async (_req: Request, res: Response) => {
const health = await getCrawlsHealth();
const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
});
/**
* GET /api/health/analytics - Analytics/aggregates status
*/
router.get('/analytics', async (_req: Request, res: Response) => {
const health = await getAnalyticsHealth();
const statusCode = health.status === 'ok' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
});
/**
* GET /api/health/full - Aggregated view of all subsystems
*/
router.get('/full', async (_req: Request, res: Response) => {
const [api, db, redis, workers, crawls, analytics] = await Promise.all([
getApiHealth(),
getDbHealth(),
getRedisHealth(),
getWorkersHealth(),
getCrawlsHealth(),
getAnalyticsHealth(),
]);
const overallStatus = determineOverallStatus(api, db, redis, workers, crawls, analytics);
const fullHealth: FullHealth = {
status: overallStatus,
api,
db,
redis,
workers,
crawls,
analytics,
};
const statusCode = overallStatus === 'ok' ? 200 : overallStatus === 'degraded' ? 200 : 503;
res.status(statusCode).json(fullHealth);
});
export default router;
// Export helper functions for reuse in other modules
export {
getApiHealth,
getDbHealth,
getRedisHealth,
getWorkersHealth,
getCrawlsHealth,
getAnalyticsHealth,
};

View File

@@ -269,13 +269,12 @@ router.get('/dispensaries/:id/profile', async (req: Request, res: Response) => {
dcp.dispensary_id,
dcp.profile_key,
dcp.profile_name,
dcp.platform,
dcp.crawler_type,
dcp.version,
dcp.status,
dcp.config,
dcp.enabled,
dcp.sandbox_attempt_count,
dcp.next_retry_at,
dcp.sandbox_attempts,
dcp.created_at,
dcp.updated_at,
d.name as dispensary_name,
@@ -318,13 +317,12 @@ router.get('/dispensaries/:id/profile', async (req: Request, res: Response) => {
id: profile.id,
profileKey: profile.profile_key,
profileName: profile.profile_name,
platform: profile.platform,
crawlerType: profile.crawler_type,
version: profile.version,
status: profile.status || profile.config?.status || 'unknown',
config: profile.config,
enabled: profile.enabled,
sandboxAttemptCount: profile.sandbox_attempt_count,
nextRetryAt: profile.next_retry_at,
sandboxAttempts: profile.sandbox_attempts || [],
createdAt: profile.created_at,
updatedAt: profile.updated_at,
},
@@ -349,7 +347,7 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons
// Get the profile key for this dispensary
const { rows } = await pool.query(`
SELECT profile_key, platform
SELECT profile_key, crawler_type
FROM dispensary_crawler_profiles
WHERE dispensary_id = $1 AND enabled = true
ORDER BY updated_at DESC
@@ -364,14 +362,14 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons
}
const profileKey = rows[0].profile_key;
const platform = rows[0].platform || 'dutchie';
const crawlerType = rows[0].crawler_type || 'dutchie';
// Construct file path
const modulePath = path.join(
__dirname,
'..',
'crawlers',
platform,
crawlerType,
'stores',
`${profileKey}.ts`
);
@@ -381,7 +379,7 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons
return res.status(404).json({
error: `Crawler module file not found: ${profileKey}.ts`,
hasModule: false,
expectedPath: `crawlers/${platform}/stores/${profileKey}.ts`,
expectedPath: `crawlers/${crawlerType}/stores/${profileKey}.ts`,
});
}
@@ -391,9 +389,9 @@ router.get('/dispensaries/:id/crawler-module', async (req: Request, res: Respons
res.json({
hasModule: true,
profileKey,
platform,
crawlerType,
fileName: `${profileKey}.ts`,
filePath: `crawlers/${platform}/stores/${profileKey}.ts`,
filePath: `crawlers/${crawlerType}/stores/${profileKey}.ts`,
content,
lines: content.split('\n').length,
});

View File

@@ -301,10 +301,19 @@ function getScopedDispensaryId(req: PublicApiRequest): { dispensaryId: number |
* Query params:
* - category: Filter by product type (e.g., 'flower', 'edible')
* - brand: Filter by brand name
* - strain_type: Filter by strain type (indica, sativa, hybrid)
* - min_price: Minimum price filter (in dollars)
* - max_price: Maximum price filter (in dollars)
* - min_thc: Minimum THC percentage filter
* - max_thc: Maximum THC percentage filter
* - on_special: Only return products on special (true/false)
* - search: Search by name or brand
* - in_stock_only: Only return in-stock products (default: false)
* - limit: Max products to return (default: 100, max: 500)
* - offset: Pagination offset (default: 0)
* - dispensary_id: (internal keys only) Filter by specific dispensary
* - sort_by: Sort field (name, price, thc, updated) (default: name)
* - sort_dir: Sort direction (asc, desc) (default: asc)
*/
router.get('/products', async (req: PublicApiRequest, res: Response) => {
try {
@@ -322,9 +331,18 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
const {
category,
brand,
strain_type,
min_price,
max_price,
min_thc,
max_thc,
on_special,
search,
in_stock_only = 'false',
limit = '100',
offset = '0'
offset = '0',
sort_by = 'name',
sort_dir = 'asc'
} = req.query;
// Build query
@@ -364,12 +382,63 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
paramIndex++;
}
// Filter by strain type (indica, sativa, hybrid)
if (strain_type) {
whereClause += ` AND LOWER(p.strain_type) = LOWER($${paramIndex})`;
params.push(strain_type);
paramIndex++;
}
// Filter by THC range
if (min_thc) {
whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) >= $${paramIndex}`;
params.push(parseFloat(min_thc as string));
paramIndex++;
}
if (max_thc) {
whereClause += ` AND CAST(NULLIF(p.thc, '') AS NUMERIC) <= $${paramIndex}`;
params.push(parseFloat(max_thc as string));
paramIndex++;
}
// Filter by on special
if (on_special === 'true' || on_special === '1') {
whereClause += ` AND s.special = TRUE`;
}
// Search by name or brand
if (search) {
whereClause += ` AND (LOWER(p.name) LIKE LOWER($${paramIndex}) OR LOWER(p.brand_name) LIKE LOWER($${paramIndex}))`;
params.push(`%${search}%`);
paramIndex++;
}
// Enforce limits
const limitNum = Math.min(parseInt(limit as string, 10) || 100, 500);
const offsetNum = parseInt(offset as string, 10) || 0;
// Build ORDER BY clause
const sortDirection = sort_dir === 'desc' ? 'DESC' : 'ASC';
let orderBy = 'p.name ASC';
switch (sort_by) {
case 'price':
orderBy = `s.rec_min_price_cents ${sortDirection} NULLS LAST`;
break;
case 'thc':
orderBy = `CAST(NULLIF(p.thc, '') AS NUMERIC) ${sortDirection} NULLS LAST`;
break;
case 'updated':
orderBy = `p.updated_at ${sortDirection}`;
break;
case 'name':
default:
orderBy = `p.name ${sortDirection}`;
}
params.push(limitNum, offsetNum);
// Query products with latest snapshot data
// Note: Price filters use HAVING clause since they reference the snapshot subquery
const { rows: products } = await dutchieAzQuery(`
SELECT
p.id,
@@ -406,13 +475,24 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
LIMIT 1
) s ON true
${whereClause}
ORDER BY p.name ASC
${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''}
ORDER BY ${orderBy}
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`, params);
// Get total count for pagination
// Get total count for pagination (include price filters if specified)
const { rows: countRows } = await dutchieAzQuery(`
SELECT COUNT(*) as total FROM dutchie_products p ${whereClause}
SELECT COUNT(*) as total FROM dutchie_products p
LEFT JOIN LATERAL (
SELECT rec_min_price_cents, special FROM dutchie_product_snapshots
WHERE dutchie_product_id = p.id
ORDER BY crawled_at DESC
LIMIT 1
) s ON true
${whereClause}
${min_price ? `AND (s.rec_min_price_cents / 100.0) >= ${parseFloat(min_price as string)}` : ''}
${max_price ? `AND (s.rec_min_price_cents / 100.0) <= ${parseFloat(max_price as string)}` : ''}
`, params.slice(0, -2));
// Transform products to backward-compatible format

238
backend/src/routes/seo.ts Normal file
View File

@@ -0,0 +1,238 @@
/**
* SEO API Routes - Content generation and management for CannaiQ marketing pages
*
* All content returned by these endpoints is sanitized to ensure
* enterprise-safe phrasing with no forbidden terminology.
*/
import { Router, Request, Response } from 'express';
import { getPool } from '../db/pool';
import { authMiddleware } from '../auth/middleware';
import { ContentValidator } from '../utils/ContentValidator';
import { generateSeoPageWithClaude } from '../services/seoGenerator';
const router = Router();
/**
* GET /api/seo/page - Get SEO page content by slug (public, sanitized)
*/
router.get('/page', async (req: Request, res: Response) => {
try {
const { slug } = req.query;
if (!slug || typeof slug !== 'string') {
return res.status(400).json({ error: 'slug query parameter required' });
}
const pool = getPool();
const result = await pool.query(
`SELECT id, slug, type, meta_title, meta_description, status, updated_at
FROM seo_pages WHERE slug = $1 AND status = 'live'`,
[slug]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Page not found' });
}
const page = result.rows[0];
// Always sanitize content before returning (safety net)
const content = {
metaTitle: page.meta_title,
metaDescription: page.meta_description,
};
const sanitizedContent = ContentValidator.sanitizeContent(content);
res.json({
id: page.id,
slug: page.slug,
type: page.type,
content: sanitizedContent,
updatedAt: page.updated_at,
});
} catch (error: any) {
console.error('[SEO] Error fetching page:', error.message);
res.status(500).json({ error: 'Failed to fetch SEO page' });
}
});
/**
* POST /api/seo/page - Create/update SEO page (admin, auto-sanitizes)
*/
router.post('/page', authMiddleware, async (req: Request, res: Response) => {
try {
const { slug, type, metaTitle, metaDescription, status = 'draft' } = req.body;
if (!slug || !type) {
return res.status(400).json({ error: 'slug and type required' });
}
// Validate and sanitize content
const content = { metaTitle, metaDescription };
const validation = ContentValidator.validate(content);
if (!validation.valid) {
console.warn(`[SEO] Forbidden terms sanitized for ${slug}:`, validation.forbiddenTerms);
}
const sanitized = validation.sanitized as { metaTitle?: string; metaDescription?: string };
const pool = getPool();
// Always store sanitized content
const result = await pool.query(
`INSERT INTO seo_pages (slug, type, page_key, meta_title, meta_description, status, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
ON CONFLICT (slug) DO UPDATE SET
type = EXCLUDED.type, meta_title = EXCLUDED.meta_title,
meta_description = EXCLUDED.meta_description,
status = EXCLUDED.status, updated_at = NOW()
RETURNING id, slug, type, status, updated_at`,
[slug, type, slug, sanitized.metaTitle || null, sanitized.metaDescription || null, status]
);
res.json({
success: true,
page: result.rows[0],
sanitized: !validation.valid,
forbiddenTermsRemoved: validation.forbiddenTerms,
});
} catch (error: any) {
console.error('[SEO] Error saving page:', error.message);
res.status(500).json({ error: 'Failed to save SEO page' });
}
});
/**
* POST /api/seo/validate - Validate content for forbidden terms
*/
router.post('/validate', async (req: Request, res: Response) => {
try {
const { content } = req.body;
if (!content) {
return res.status(400).json({ error: 'content is required' });
}
const validation = ContentValidator.validate(content);
res.json({
valid: validation.valid,
forbiddenTerms: validation.forbiddenTerms,
sanitized: validation.sanitized,
approvedPhrases: ContentValidator.getApprovedPhrases(),
});
} catch (error: any) {
res.status(500).json({ error: 'Failed to validate content' });
}
});
/**
* GET /api/seo/state/:stateCode - State SEO data with metrics
*/
router.get('/state/:stateCode', async (req: Request, res: Response) => {
try {
const { stateCode } = req.params;
const code = stateCode.toUpperCase();
const pool = getPool();
const metricsResult = await pool.query(`
SELECT COUNT(DISTINCT d.id) as dispensary_count,
COUNT(DISTINCT p.id) as product_count,
COUNT(DISTINCT p.brand_name) as brand_count
FROM dispensaries d
LEFT JOIN dutchie_products p ON p.dispensary_id = d.id
WHERE d.state = $1`, [code]);
const brandsResult = await pool.query(`
SELECT brand_name, COUNT(*) as product_count
FROM dutchie_products p JOIN dispensaries d ON p.dispensary_id = d.id
WHERE d.state = $1 AND p.brand_name IS NOT NULL
GROUP BY brand_name ORDER BY product_count DESC LIMIT 10`, [code]);
const metrics = metricsResult.rows[0];
const response = ContentValidator.sanitizeContent({
stateCode: code,
metrics: {
dispensaryCount: parseInt(metrics.dispensary_count, 10) || 0,
productCount: parseInt(metrics.product_count, 10) || 0,
brandCount: parseInt(metrics.brand_count, 10) || 0,
},
topBrands: brandsResult.rows.map(r => ({
name: r.brand_name,
productCount: parseInt(r.product_count, 10),
})),
});
res.json(response);
} catch (error: any) {
console.error('[SEO] Error fetching state data:', error.message);
res.status(500).json({ error: 'Failed to fetch state SEO data' });
}
});
/**
* POST /api/seo/pages/:id/generate - Generate SEO content for a page
*/
router.post('/pages/:id/generate', authMiddleware, async (req: Request, res: Response) => {
try {
const { id } = req.params;
const pageId = parseInt(id, 10);
if (isNaN(pageId)) {
return res.status(400).json({ error: 'Invalid page ID' });
}
const content = await generateSeoPageWithClaude(pageId);
res.json({ success: true, content });
} catch (error: any) {
console.error('[SEO] Error generating page:', error.message);
res.status(500).json({ error: error.message || 'Failed to generate SEO content' });
}
});
/**
* GET /api/seo/public/content - Get full SEO page content by slug (public)
*/
router.get('/public/content', async (req: Request, res: Response) => {
try {
const { slug } = req.query;
if (!slug || typeof slug !== 'string') {
return res.status(400).json({ error: 'slug query parameter required' });
}
const pool = getPool();
// Find page and content
const result = await pool.query(`
SELECT p.id, p.slug, p.type, p.status,
c.blocks, c.meta_title, c.meta_description, c.h1,
c.canonical_url, c.og_title, c.og_description, c.og_image_url
FROM seo_pages p
LEFT JOIN seo_page_contents c ON c.page_id = p.id
WHERE p.slug = $1 AND p.status = 'live'
`, [slug]);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Page not found or not published' });
}
const row = result.rows[0];
const sanitized = ContentValidator.sanitizeContent({
meta: {
title: row.meta_title,
description: row.meta_description,
h1: row.h1,
canonicalUrl: row.canonical_url,
ogTitle: row.og_title,
ogDescription: row.og_description,
ogImageUrl: row.og_image_url
},
blocks: row.blocks || []
});
res.json({
slug: row.slug,
type: row.type,
...sanitized
});
} catch (error: any) {
console.error('[SEO] Error fetching public content:', error.message);
res.status(500).json({ error: 'Failed to fetch SEO content' });
}
});
export default router;

View File

@@ -0,0 +1,622 @@
/**
* Workers API Routes
*
* Provider-agnostic worker management and job monitoring.
* Replaces legacy /api/dutchie-az/admin/schedules and /api/dutchie-az/monitor/* routes.
*
* Endpoints:
* GET /api/workers - List all workers/schedules
* GET /api/workers/active - List currently active workers
* GET /api/workers/schedule - Get all job schedules
* GET /api/workers/:workerName - Get specific worker details
* GET /api/workers/:workerName/scope - Get worker's scope (states, etc.)
* GET /api/workers/:workerName/stats - Get worker statistics
* GET /api/workers/:workerName/logs - Get worker's recent logs
* POST /api/workers/:workerName/trigger - Trigger worker manually
*
* GET /api/monitor/jobs - Get recent job history
* GET /api/monitor/active-jobs - Get currently running jobs
* GET /api/monitor/summary - Get monitoring summary
*/
import { Router, Request, Response } from 'express';
import { getPool } from '../dutchie-az/db/connection';
const router = Router();
// ============================================================
// WORKER TYPES
// ============================================================
interface Worker {
id: number;
worker_name: string;
run_role: string;
scope: string[];
description: string;
enabled: boolean;
base_interval_minutes: number;
jitter_minutes: number;
next_run_at: string | null;
last_run_at: string | null;
last_status: string | null;
last_seen: string | null;
visibility_lost: number;
visibility_restored: number;
}
interface JobLog {
id: number;
worker_name: string;
run_role: string;
job_name: string;
status: string;
started_at: string;
completed_at: string | null;
duration_seconds: number | null;
items_processed: number;
items_succeeded: number;
items_failed: number;
error_message: string | null;
scope: string[];
}
// ============================================================
// HELPERS
// ============================================================
function parseScope(jobConfig: any): string[] {
if (!jobConfig) return [];
if (jobConfig.scope) return Array.isArray(jobConfig.scope) ? jobConfig.scope : [jobConfig.scope];
if (jobConfig.states) return Array.isArray(jobConfig.states) ? jobConfig.states : [jobConfig.states];
return [];
}
function extractWorkerName(jobName: string, jobConfig: any): string {
// Priority: explicit worker_name > job_config.worker_name > derive from job_name
if (jobConfig?.worker_name) return jobConfig.worker_name;
// Extract from job_name like "dutchie_az_product_crawl" -> "ProductCrawl"
const parts = jobName.replace(/^(dutchie_)?az_?/i, '').split('_');
return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join('');
}
function extractRunRole(jobName: string, jobConfig: any): string {
if (jobConfig?.run_role) return jobConfig.run_role;
// Map job names to roles
const roleMap: Record<string, string> = {
'menu_detection': 'StoreDiscovery',
'menu_detection_single': 'StoreDiscovery',
'dutchie_product_crawl': 'ProductSync',
'product_crawl': 'ProductSync',
'analytics_refresh': 'Analytics',
'id_resolution': 'IdResolution',
};
for (const [key, role] of Object.entries(roleMap)) {
if (jobName.toLowerCase().includes(key.toLowerCase())) {
return role;
}
}
return 'General';
}
// ============================================================
// WORKERS ROUTES
// ============================================================
/**
* GET /api/workers - List all workers/schedules
*/
router.get('/', async (_req: Request, res: Response) => {
try {
const pool = getPool();
const { rows } = await pool.query(`
SELECT
id,
job_name,
description,
enabled,
base_interval_minutes,
jitter_minutes,
next_run_at,
last_run_at,
last_status,
job_config
FROM job_schedules
ORDER BY enabled DESC, last_run_at DESC NULLS LAST
`);
const workers: Worker[] = rows.map((row: any) => ({
id: row.id,
worker_name: extractWorkerName(row.job_name, row.job_config),
run_role: extractRunRole(row.job_name, row.job_config),
scope: parseScope(row.job_config),
description: row.description || row.job_name,
enabled: row.enabled,
base_interval_minutes: row.base_interval_minutes,
jitter_minutes: row.jitter_minutes,
next_run_at: row.next_run_at?.toISOString() || null,
last_run_at: row.last_run_at?.toISOString() || null,
last_status: row.last_status,
last_seen: row.last_run_at?.toISOString() || null,
visibility_lost: 0,
visibility_restored: 0,
}));
res.json({ success: true, workers });
} catch (error: any) {
console.error('[Workers] Error listing workers:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/workers/active - List currently active workers
*/
router.get('/active', async (_req: Request, res: Response) => {
try {
const pool = getPool();
const { rows } = await pool.query(`
SELECT DISTINCT ON (claimed_by)
claimed_by as worker_id,
worker_hostname,
job_type,
started_at,
last_heartbeat_at
FROM dispensary_crawl_jobs
WHERE status = 'running'
AND claimed_by IS NOT NULL
ORDER BY claimed_by, started_at DESC
`);
const activeWorkers = rows.map((row: any) => ({
worker_id: row.worker_id,
hostname: row.worker_hostname,
current_job_type: row.job_type,
started_at: row.started_at?.toISOString(),
last_heartbeat: row.last_heartbeat_at?.toISOString(),
run_role: extractRunRole(row.job_type, null),
}));
res.json({ success: true, active_workers: activeWorkers, count: activeWorkers.length });
} catch (error: any) {
console.error('[Workers] Error getting active workers:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/workers/schedule - Get all job schedules (alias for /)
*/
router.get('/schedule', async (req: Request, res: Response) => {
// Delegate to main workers endpoint
const pool = getPool();
try {
const { rows } = await pool.query(`
SELECT
id,
job_name,
description,
enabled,
base_interval_minutes,
jitter_minutes,
next_run_at,
last_run_at,
last_status,
job_config
FROM job_schedules
ORDER BY next_run_at ASC NULLS LAST
`);
res.json({ success: true, schedules: rows });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/workers/:workerIdOrName - Get specific worker details
*/
router.get('/:workerIdOrName', async (req: Request, res: Response) => {
try {
const { workerIdOrName } = req.params;
const pool = getPool();
// Try to find by ID or job_name
const { rows } = await pool.query(`
SELECT
id,
job_name,
description,
enabled,
base_interval_minutes,
jitter_minutes,
next_run_at,
last_run_at,
last_status,
job_config
FROM job_schedules
WHERE id = $1::int OR job_name ILIKE $2
LIMIT 1
`, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
const row = rows[0];
const worker: Worker = {
id: row.id,
worker_name: extractWorkerName(row.job_name, row.job_config),
run_role: extractRunRole(row.job_name, row.job_config),
scope: parseScope(row.job_config),
description: row.description || row.job_name,
enabled: row.enabled,
base_interval_minutes: row.base_interval_minutes,
jitter_minutes: row.jitter_minutes,
next_run_at: row.next_run_at?.toISOString() || null,
last_run_at: row.last_run_at?.toISOString() || null,
last_status: row.last_status,
last_seen: row.last_run_at?.toISOString() || null,
visibility_lost: 0,
visibility_restored: 0,
};
res.json({ success: true, worker });
} catch (error: any) {
console.error('[Workers] Error getting worker:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/workers/:workerIdOrName/scope - Get worker's scope
*/
router.get('/:workerIdOrName/scope', async (req: Request, res: Response) => {
try {
const { workerIdOrName } = req.params;
const pool = getPool();
const { rows } = await pool.query(`
SELECT job_config
FROM job_schedules
WHERE id = $1::int OR job_name ILIKE $2
LIMIT 1
`, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]);
if (rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
const scope = parseScope(rows[0].job_config);
res.json({ success: true, scope });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/workers/:workerIdOrName/stats - Get worker statistics
*/
router.get('/:workerIdOrName/stats', async (req: Request, res: Response) => {
try {
const { workerIdOrName } = req.params;
const pool = getPool();
// Get schedule info
const scheduleResult = await pool.query(`
SELECT id, job_name FROM job_schedules
WHERE id = $1::int OR job_name ILIKE $2
LIMIT 1
`, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]);
if (scheduleResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
const scheduleId = scheduleResult.rows[0].id;
// Get stats
const statsResult = await pool.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'success') as success_count,
COUNT(*) FILTER (WHERE status IN ('error', 'partial')) as failure_count,
COUNT(*) as total_runs,
AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) as avg_duration_seconds,
SUM(items_processed) as total_items_processed,
MAX(completed_at) as last_completed
FROM job_run_logs
WHERE schedule_id = $1
AND started_at > NOW() - INTERVAL '7 days'
`, [scheduleId]);
const stats = statsResult.rows[0];
res.json({
success: true,
stats: {
success_count: parseInt(stats.success_count) || 0,
failure_count: parseInt(stats.failure_count) || 0,
total_runs: parseInt(stats.total_runs) || 0,
avg_duration_seconds: parseFloat(stats.avg_duration_seconds) || 0,
total_items_processed: parseInt(stats.total_items_processed) || 0,
last_completed: stats.last_completed?.toISOString() || null,
}
});
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/workers/:workerIdOrName/logs - Get worker's recent logs
*/
router.get('/:workerIdOrName/logs', async (req: Request, res: Response) => {
try {
const { workerIdOrName } = req.params;
const limit = parseInt(req.query.limit as string) || 20;
const pool = getPool();
// Get schedule info
const scheduleResult = await pool.query(`
SELECT id, job_name, job_config FROM job_schedules
WHERE id = $1::int OR job_name ILIKE $2
LIMIT 1
`, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]);
if (scheduleResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
const schedule = scheduleResult.rows[0];
const { rows } = await pool.query(`
SELECT
id,
job_name,
status,
started_at,
completed_at,
EXTRACT(EPOCH FROM (completed_at - started_at)) as duration_seconds,
items_processed,
items_succeeded,
items_failed,
error_message,
metadata
FROM job_run_logs
WHERE schedule_id = $1
ORDER BY started_at DESC
LIMIT $2
`, [schedule.id, limit]);
const logs: JobLog[] = rows.map((row: any) => ({
id: row.id,
worker_name: extractWorkerName(schedule.job_name, schedule.job_config),
run_role: extractRunRole(schedule.job_name, schedule.job_config),
job_name: row.job_name,
status: row.status,
started_at: row.started_at?.toISOString(),
completed_at: row.completed_at?.toISOString() || null,
duration_seconds: row.duration_seconds ? Math.round(row.duration_seconds) : null,
items_processed: row.items_processed || 0,
items_succeeded: row.items_succeeded || 0,
items_failed: row.items_failed || 0,
error_message: row.error_message,
scope: parseScope(schedule.job_config),
}));
res.json({ success: true, logs });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/workers/:workerIdOrName/trigger - Trigger worker manually
*/
router.post('/:workerIdOrName/trigger', async (req: Request, res: Response) => {
try {
const { workerIdOrName } = req.params;
const pool = getPool();
// Get schedule info
const scheduleResult = await pool.query(`
SELECT id, job_name FROM job_schedules
WHERE id = $1::int OR job_name ILIKE $2
LIMIT 1
`, [parseInt(workerIdOrName) || 0, `%${workerIdOrName}%`]);
if (scheduleResult.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Worker not found' });
}
const scheduleId = scheduleResult.rows[0].id;
// Set next_run_at to now to trigger immediately
await pool.query(`
UPDATE job_schedules
SET next_run_at = NOW()
WHERE id = $1
`, [scheduleId]);
res.json({ success: true, message: 'Worker triggered', schedule_id: scheduleId });
} catch (error: any) {
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// MONITOR ROUTES (for /api/monitor prefix)
// ============================================================
/**
* GET /api/monitor/jobs - Get recent job history
*/
router.get('/jobs', async (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string) || 50;
const status = req.query.status as string | undefined;
const pool = getPool();
let query = `
SELECT
j.id,
j.job_name,
j.status,
j.started_at,
j.completed_at,
EXTRACT(EPOCH FROM (j.completed_at - j.started_at)) as duration_seconds,
j.items_processed,
j.items_succeeded,
j.items_failed,
j.error_message,
j.metadata,
s.job_config
FROM job_run_logs j
LEFT JOIN job_schedules s ON j.schedule_id = s.id
WHERE 1=1
`;
const params: any[] = [];
if (status) {
params.push(status);
query += ` AND j.status = $${params.length}`;
}
params.push(limit);
query += ` ORDER BY j.started_at DESC LIMIT $${params.length}`;
const { rows } = await pool.query(query, params);
const jobs: JobLog[] = rows.map((row: any) => ({
id: row.id,
worker_name: extractWorkerName(row.job_name, row.job_config),
run_role: extractRunRole(row.job_name, row.job_config),
job_name: row.job_name,
status: row.status,
started_at: row.started_at?.toISOString(),
completed_at: row.completed_at?.toISOString() || null,
duration_seconds: row.duration_seconds ? Math.round(row.duration_seconds) : null,
items_processed: row.items_processed || 0,
items_succeeded: row.items_succeeded || 0,
items_failed: row.items_failed || 0,
error_message: row.error_message,
scope: parseScope(row.job_config),
}));
res.json({ success: true, jobs, count: jobs.length });
} catch (error: any) {
console.error('[Monitor] Error getting jobs:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/monitor/active-jobs - Get currently running jobs
*/
router.get('/active-jobs', async (req: Request, res: Response) => {
try {
const pool = getPool();
const { rows } = await pool.query(`
SELECT
id,
dispensary_id,
job_type,
status,
worker_hostname,
started_at,
last_heartbeat_at,
products_found,
error_message,
metadata
FROM dispensary_crawl_jobs
WHERE status = 'running'
ORDER BY started_at DESC
`);
const activeJobs = rows.map((row: any) => ({
id: row.id,
dispensary_id: row.dispensary_id,
job_type: row.job_type,
worker_name: extractWorkerName(row.job_type, null),
run_role: extractRunRole(row.job_type, null),
status: row.status,
hostname: row.worker_hostname,
started_at: row.started_at?.toISOString(),
last_heartbeat: row.last_heartbeat_at?.toISOString(),
products_found: row.products_found || 0,
error_message: row.error_message,
}));
res.json({ success: true, active_jobs: activeJobs, count: activeJobs.length });
} catch (error: any) {
console.error('[Monitor] Error getting active jobs:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/monitor/summary - Get monitoring summary
*/
router.get('/summary', async (req: Request, res: Response) => {
try {
const pool = getPool();
// Get summary stats
const [scheduleStats, jobStats, activeJobs] = await Promise.all([
pool.query(`
SELECT
COUNT(*) as total_schedules,
COUNT(*) FILTER (WHERE enabled = true) as enabled_schedules,
COUNT(*) FILTER (WHERE last_status = 'success') as last_success,
COUNT(*) FILTER (WHERE last_status IN ('error', 'partial')) as last_failed
FROM job_schedules
`),
pool.query(`
SELECT
COUNT(*) as total_runs,
COUNT(*) FILTER (WHERE status = 'success') as success_count,
COUNT(*) FILTER (WHERE status IN ('error', 'partial')) as failure_count,
COUNT(*) FILTER (WHERE status = 'running') as running_count
FROM job_run_logs
WHERE started_at > NOW() - INTERVAL '24 hours'
`),
pool.query(`
SELECT COUNT(*) as active_count
FROM dispensary_crawl_jobs
WHERE status = 'running'
`)
]);
const schedules = scheduleStats.rows[0];
const jobs = jobStats.rows[0];
const active = activeJobs.rows[0];
res.json({
success: true,
summary: {
schedules: {
total: parseInt(schedules.total_schedules) || 0,
enabled: parseInt(schedules.enabled_schedules) || 0,
last_success: parseInt(schedules.last_success) || 0,
last_failed: parseInt(schedules.last_failed) || 0,
},
jobs_24h: {
total: parseInt(jobs.total_runs) || 0,
success: parseInt(jobs.success_count) || 0,
failed: parseInt(jobs.failure_count) || 0,
running: parseInt(jobs.running_count) || 0,
},
active_crawl_jobs: parseInt(active.active_count) || 0,
}
});
} catch (error: any) {
console.error('[Monitor] Error getting summary:', error);
res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,200 @@
/**
* SEO Page Generator Service
*
* Generates SEO content for pages using structured prompts,
* sanitizes output, and stores in seo_page_contents.
*/
import { getPool } from '../db/pool';
const pool = getPool();
import { ContentValidator } from '../utils/ContentValidator';
interface SeoPage {
id: number;
type: string;
slug: string;
page_key: string;
primary_keyword: string | null;
}
interface GeneratedSeoPayload {
title: string;
metaDescription: string;
h1: string;
blocks: any[];
}
interface SeoPageContent {
id: number;
pageId: number;
blocks: any[];
metaTitle: string;
metaDescription: string;
h1: string;
}
interface GenerationSpec {
type: string;
slug: string;
primaryKeyword: string;
metrics?: any;
topBrands?: any[];
competitorName?: string;
}
/**
* Build generation spec based on page type
*/
async function buildSeoGenerationSpec(page: SeoPage): Promise<GenerationSpec> {
const spec: GenerationSpec = {
type: page.type,
slug: page.slug,
primaryKeyword: page.primary_keyword || `${page.type} cannabis data`
};
if (page.type === 'state') {
// Fetch state metrics
const stateCode = page.page_key.replace('states/', '').toUpperCase();
const metricsResult = await pool.query(`
SELECT store_count, total_products, unique_brands, state_name
FROM mv_state_metrics WHERE state = $1
`, [stateCode]);
if (metricsResult.rows[0]) {
spec.metrics = {
dispensaryCount: parseInt(metricsResult.rows[0].store_count) || 0,
productCount: parseInt(metricsResult.rows[0].total_products) || 0,
brandCount: parseInt(metricsResult.rows[0].unique_brands) || 0,
stateName: metricsResult.rows[0].state_name || stateCode
};
}
// Fetch top brands
const brandsResult = await pool.query(`
SELECT brand_name, COUNT(*) as count
FROM dutchie_products p
JOIN dispensaries d ON p.dispensary_id = d.id
WHERE d.state = $1 AND p.brand_name IS NOT NULL
GROUP BY brand_name ORDER BY count DESC LIMIT 6
`, [stateCode]);
spec.topBrands = brandsResult.rows;
}
if (page.type === 'competitor_alternative') {
spec.competitorName = page.slug.split('/').pop()?.replace(/-/g, ' ') || 'competitor';
}
return spec;
}
/**
* Generate SEO content based on spec (uses templates, not actual Claude API)
*/
function generateSeoContent(spec: GenerationSpec): GeneratedSeoPayload {
// Template-based generation (production would call Claude API)
if (spec.type === 'state' && spec.metrics) {
const { stateName, dispensaryCount, productCount, brandCount } = spec.metrics;
return {
title: `${stateName} Cannabis Market Data | CannaIQ`,
metaDescription: `Real-time ${stateName} cannabis market intelligence. Monitor ${dispensaryCount}+ dispensaries, ${productCount.toLocaleString()}+ products, and ${brandCount}+ brands with continuously refreshed data.`,
h1: `${stateName} Cannabis Market Intelligence`,
blocks: [
{
type: 'hero',
headline: `${stateName} Cannabis Market Data`,
subheadline: `Continuously refreshed pricing, product, and brand data from ${dispensaryCount}+ ${stateName} dispensaries.`,
ctaPrimary: { text: 'Get Market Data', href: `/demo?state=${spec.slug.split('/').pop()}` }
},
{
type: 'stats',
headline: 'Market Snapshot',
items: [
{ value: dispensaryCount.toString(), label: 'Dispensaries', description: 'Monitored in real-time' },
{ value: productCount.toLocaleString(), label: 'Products', description: 'With live pricing' },
{ value: brandCount.toString(), label: 'Brands', description: 'Tracked across retailers' }
]
},
{
type: 'intro',
content: `CannaIQ monitors ${dispensaryCount} dispensaries, ${productCount.toLocaleString()} products, and ${brandCount} brands in ${stateName}, with listings and availability continuously updated throughout the day.`
},
{
type: 'topBrands',
headline: `Top ${stateName} Cannabis Brands`,
brands: spec.topBrands?.slice(0, 6).map(b => ({ name: b.brand_name, productCount: parseInt(b.count) })) || []
},
{
type: 'cta',
headline: `Get ${stateName} Market Intelligence`,
subheadline: 'Access real-time pricing, brand distribution, and competitive insights.',
ctaPrimary: { text: 'Request Demo', href: '/demo' }
}
]
};
}
// Generic fallback
return {
title: `${spec.primaryKeyword} | CannaIQ`,
metaDescription: `Cannabis market intelligence for ${spec.primaryKeyword}. Real-time data continuously refreshed.`,
h1: spec.primaryKeyword,
blocks: [
{ type: 'hero', headline: spec.primaryKeyword, subheadline: 'Real-time cannabis market data', ctaPrimary: { text: 'Learn More', href: '/demo' } },
{ type: 'cta', headline: 'Get Started', subheadline: 'Request a demo today.', ctaPrimary: { text: 'Request Demo', href: '/demo' } }
]
};
}
/**
* Main generation pipeline
*/
export async function generateSeoPageWithClaude(pageId: number): Promise<SeoPageContent> {
// 1. Load page
const pageResult = await pool.query(
'SELECT id, type, slug, page_key, primary_keyword FROM seo_pages WHERE id = $1',
[pageId]
);
if (pageResult.rows.length === 0) {
throw new Error(`SEO page not found: ${pageId}`);
}
const page = pageResult.rows[0] as SeoPage;
// 2. Build spec
const spec = await buildSeoGenerationSpec(page);
// 3. Generate content
const payload = generateSeoContent(spec);
// 4. Sanitize (removes forbidden terms)
const sanitized = ContentValidator.sanitizeContent(payload) as GeneratedSeoPayload;
// 5. Upsert content
const result = await pool.query(`
INSERT INTO seo_page_contents (page_id, blocks, meta_title, meta_description, h1, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (page_id) DO UPDATE SET
blocks = EXCLUDED.blocks,
meta_title = EXCLUDED.meta_title,
meta_description = EXCLUDED.meta_description,
h1 = EXCLUDED.h1,
updated_at = NOW()
RETURNING id, page_id, blocks, meta_title, meta_description, h1
`, [pageId, JSON.stringify(sanitized.blocks), sanitized.title, sanitized.metaDescription, sanitized.h1]);
// 6. Update page status
await pool.query(
`UPDATE seo_pages SET status = 'live', last_generated_at = NOW() WHERE id = $1`,
[pageId]
);
const row = result.rows[0];
return {
id: row.id,
pageId: row.page_id,
blocks: row.blocks,
metaTitle: row.meta_title,
metaDescription: row.meta_description,
h1: row.h1
};
}

View File

@@ -0,0 +1,179 @@
/**
* ContentValidator - Redaction and Sanitization for Public Content
*
* Ensures all public-facing content (SEO pages, homepage, API responses)
* uses approved enterprise-safe phrasing and never exposes internal
* data collection mechanics.
*
* Source of truth for forbidden/approved terminology.
*/
// Forbidden terms that must NEVER appear in public content
const FORBIDDEN_TERMS = [
'crawl', 'crawled', 'crawler', 'crawling',
'scrape', 'scraped', 'scraper', 'scraping',
'snapshot', 'snapshots',
'ingestion', 'pipeline', 'ETL',
'worker', 'workers',
'bot', 'bots',
'extraction', 'harvesting', 'polling',
'data collector', 'collectors',
] as const;
// Approved replacement phrases
const APPROVED_PHRASES = [
'continuously refreshed',
'updated daily',
'up-to-date data',
'current availability',
'latest listings',
'real-time market insights',
'real-time cannabis market intelligence',
'continuously monitored markets',
'fresh, reliable market information',
] as const;
// Context-aware replacements (pattern -> replacement)
const REPLACEMENTS: Array<{ pattern: RegExp; replacement: string }> = [
// Data collection verbs → neutral phrasing
{ pattern: /\b(crawl|scrape)s?\b/gi, replacement: 'refresh' },
{ pattern: /\b(crawled|scraped)\b/gi, replacement: 'updated' },
{ pattern: /\b(crawling|scraping)\b/gi, replacement: 'refreshing' },
{ pattern: /\b(crawler|scraper)s?\b/gi, replacement: 'data service' },
// Technical terms → business terms
{ pattern: /\bsnapshots?\b/gi, replacement: 'market data' },
{ pattern: /\bingestion\b/gi, replacement: 'data processing' },
{ pattern: /\bpipeline\b/gi, replacement: 'data flow' },
{ pattern: /\bETL\b/g, replacement: 'data processing' },
{ pattern: /\bworkers?\b/gi, replacement: 'services' },
{ pattern: /\bbots?\b/gi, replacement: 'automated systems' },
{ pattern: /\bextraction\b/gi, replacement: 'data gathering' },
{ pattern: /\bharvesting\b/gi, replacement: 'collecting' },
{ pattern: /\bpolling\b/gi, replacement: 'monitoring' },
{ pattern: /\bdata collectors?\b/gi, replacement: 'data services' },
];
// Build regex for detection
const FORBIDDEN_PATTERN = new RegExp(
`\\b(${FORBIDDEN_TERMS.join('|')})\\b`,
'gi'
);
export class ContentValidator {
/**
* Check if text contains any forbidden terms
*/
static hasForbiddenTerms(text: string): boolean {
if (typeof text !== 'string') return false;
return FORBIDDEN_PATTERN.test(text);
}
/**
* Get list of forbidden terms found in text
*/
static findForbiddenTerms(text: string): string[] {
if (typeof text !== 'string') return [];
const matches = text.match(FORBIDDEN_PATTERN);
return matches ? Array.from(new Set(matches.map(m => m.toLowerCase()))) : [];
}
/**
* Sanitize a string by replacing forbidden terms
*/
static sanitizeString(text: string): string {
if (typeof text !== 'string') return text;
let sanitized = text;
for (const { pattern, replacement } of REPLACEMENTS) {
sanitized = sanitized.replace(pattern, replacement);
}
return sanitized;
}
/**
* Recursively sanitize content (strings, objects, arrays)
* Preserves structure while sanitizing all string values
*/
static sanitizeContent<T>(content: T): T {
if (content === null || content === undefined) {
return content;
}
if (typeof content === 'string') {
return this.sanitizeString(content) as T;
}
if (Array.isArray(content)) {
return content.map(item => this.sanitizeContent(item)) as T;
}
if (typeof content === 'object') {
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(content)) {
sanitized[key] = this.sanitizeContent(value);
}
return sanitized as T;
}
// Numbers, booleans, etc. pass through unchanged
return content;
}
/**
* Validate content and throw if forbidden terms found
* Use for build-time / deploy-time validation
*/
static validateOrThrow(content: unknown, context?: string): void {
const text = typeof content === 'string'
? content
: JSON.stringify(content);
const forbidden = this.findForbiddenTerms(text);
if (forbidden.length > 0) {
const ctx = context ? ` in ${context}` : '';
throw new Error(
`Forbidden terms found${ctx}: ${forbidden.join(', ')}. ` +
`Public content must use approved enterprise-safe phrasing.`
);
}
}
/**
* Validate and return validation result (non-throwing)
*/
static validate(content: unknown): {
valid: boolean;
forbiddenTerms: string[];
sanitized: unknown;
} {
const text = typeof content === 'string'
? content
: JSON.stringify(content);
const forbiddenTerms = this.findForbiddenTerms(text);
const sanitized = this.sanitizeContent(content);
return {
valid: forbiddenTerms.length === 0,
forbiddenTerms,
sanitized,
};
}
/**
* Get approved phrases for reference
*/
static getApprovedPhrases(): readonly string[] {
return APPROVED_PHRASES;
}
/**
* Get forbidden terms for reference
*/
static getForbiddenTerms(): readonly string[] {
return FORBIDDEN_TERMS;
}
}
export default ContentValidator;

View File

@@ -0,0 +1,115 @@
/**
* HomepageValidator - Content validation for homepage and marketing pages
*
* Validates that homepage content adheres to enterprise-safe phrasing
* and contains no forbidden terminology.
*/
import { ContentValidator } from './ContentValidator';
export interface HomepageContent {
hero?: {
headline?: string;
subheadline?: string;
ctaPrimary?: string;
ctaSecondary?: string;
};
features?: Array<{
title: string;
description: string;
}>;
stats?: Array<{
label: string;
value: string;
}>;
testimonials?: Array<{
quote: string;
author: string;
}>;
faq?: Array<{
question: string;
answer: string;
}>;
[key: string]: unknown;
}
export interface ValidationResult {
valid: boolean;
forbiddenTerms: string[];
warnings: string[];
}
/**
* Validate homepage content for forbidden terms
* Throws an error if forbidden terms are found (for build/deploy)
*/
export function validateHomepageContent(content: HomepageContent): void {
ContentValidator.validateOrThrow(content, 'homepage content');
}
/**
* Validate homepage content (non-throwing)
* Returns validation result with details
*/
export function checkHomepageContent(content: HomepageContent): ValidationResult {
const result = ContentValidator.validate(content);
const warnings: string[] = [];
// Check for potential issues even if technically valid
const jsonContent = JSON.stringify(content).toLowerCase();
// Warn about terms that might be close to forbidden
if (jsonContent.includes('data') && jsonContent.includes('collect')) {
warnings.push('Content mentions "data" and "collect" - ensure context is appropriate');
}
if (jsonContent.includes('automat')) {
warnings.push('Content mentions automation - verify phrasing is enterprise-appropriate');
}
return {
valid: result.valid,
forbiddenTerms: result.forbiddenTerms,
warnings,
};
}
/**
* Sanitize homepage content
* Returns cleaned content with forbidden terms replaced
*/
export function sanitizeHomepageContent<T extends HomepageContent>(content: T): T {
return ContentValidator.sanitizeContent(content);
}
/**
* Validate and sanitize in one call
* Logs warnings but returns sanitized content
*/
export function processHomepageContent<T extends HomepageContent>(
content: T,
options?: { logWarnings?: boolean }
): T {
const check = checkHomepageContent(content);
if (options?.logWarnings && check.warnings.length > 0) {
console.warn('[HomepageValidator] Warnings:', check.warnings);
}
if (!check.valid) {
console.warn(
'[HomepageValidator] Forbidden terms found and sanitized:',
check.forbiddenTerms
);
}
return sanitizeHomepageContent(content);
}
export default {
validateHomepageContent,
checkHomepageContent,
sanitizeHomepageContent,
processHomepageContent,
};

View File

@@ -0,0 +1,124 @@
/**
* ContentValidator Unit Tests
*/
import { ContentValidator } from '../ContentValidator';
describe('ContentValidator', () => {
describe('hasForbiddenTerms', () => {
it('detects forbidden terms', () => {
expect(ContentValidator.hasForbiddenTerms('We crawl dispensary data')).toBe(true);
expect(ContentValidator.hasForbiddenTerms('Data is scraped daily')).toBe(true);
expect(ContentValidator.hasForbiddenTerms('Our crawler runs hourly')).toBe(true);
expect(ContentValidator.hasForbiddenTerms('ETL pipeline processes data')).toBe(true);
expect(ContentValidator.hasForbiddenTerms('Workers harvest data')).toBe(true);
});
it('returns false for clean text', () => {
expect(ContentValidator.hasForbiddenTerms('Real-time market insights')).toBe(false);
expect(ContentValidator.hasForbiddenTerms('Continuously refreshed data')).toBe(false);
expect(ContentValidator.hasForbiddenTerms('Latest cannabis listings')).toBe(false);
});
});
describe('findForbiddenTerms', () => {
it('returns all forbidden terms found', () => {
const result = ContentValidator.findForbiddenTerms(
'Our crawler scrapes dispensary snapshots using a pipeline'
);
expect(result).toContain('crawler');
expect(result).toContain('scrapes');
expect(result).toContain('snapshots');
expect(result).toContain('pipeline');
});
});
describe('sanitizeString', () => {
it('replaces forbidden terms', () => {
expect(ContentValidator.sanitizeString('Data is crawled daily'))
.toBe('Data is updated daily');
expect(ContentValidator.sanitizeString('Our scraper runs'))
.toBe('Our data service runs');
expect(ContentValidator.sanitizeString('View snapshots'))
.toBe('View market data');
});
it('preserves clean text', () => {
const clean = 'Real-time cannabis market intelligence';
expect(ContentValidator.sanitizeString(clean)).toBe(clean);
});
});
describe('sanitizeContent', () => {
it('sanitizes nested objects', () => {
const dirty = {
title: 'Our Crawler',
blocks: [
{ type: 'text', content: 'Data is scraped hourly' },
{ type: 'stats', items: ['100 snapshots per day'] },
],
};
const result = ContentValidator.sanitizeContent(dirty);
expect(result.title).toBe('Our data service');
expect(result.blocks[0].content).toBe('Data is updated hourly');
expect(result.blocks[1].items[0]).toBe('100 market data per day');
});
it('handles null and undefined', () => {
expect(ContentValidator.sanitizeContent(null)).toBe(null);
expect(ContentValidator.sanitizeContent(undefined)).toBe(undefined);
});
it('preserves non-string values', () => {
const input = { count: 42, active: true, data: null };
const result = ContentValidator.sanitizeContent(input);
expect(result.count).toBe(42);
expect(result.active).toBe(true);
expect(result.data).toBe(null);
});
});
describe('validate', () => {
it('returns valid=true for clean content', () => {
const result = ContentValidator.validate({
title: 'Real-time Market Data',
description: 'Continuously refreshed cannabis insights',
});
expect(result.valid).toBe(true);
expect(result.forbiddenTerms).toHaveLength(0);
});
it('returns valid=false with forbidden terms list', () => {
const result = ContentValidator.validate({
title: 'Our Crawler',
description: 'Scraping dispensary data',
});
expect(result.valid).toBe(false);
expect(result.forbiddenTerms).toContain('crawler');
expect(result.forbiddenTerms).toContain('scraping');
});
it('returns sanitized version', () => {
const result = ContentValidator.validate({
title: 'Crawler Status',
});
expect(result.sanitized).toEqual({ title: 'data service Status' });
});
});
describe('validateOrThrow', () => {
it('throws on forbidden terms', () => {
expect(() => {
ContentValidator.validateOrThrow({ text: 'Our crawler' }, 'test content');
}).toThrow(/Forbidden terms found in test content/);
});
it('does not throw on clean content', () => {
expect(() => {
ContentValidator.validateOrThrow({ text: 'Real-time data' });
}).not.toThrow();
});
});
});

View File

@@ -7,8 +7,8 @@
<title>CannaIQ - Cannabis Menu Intelligence Platform</title>
<meta name="description" content="CannaIQ provides real-time cannabis dispensary menu data, product tracking, and analytics for dispensaries across Arizona." />
<meta name="keywords" content="cannabis, dispensary, menu, products, analytics, Arizona" />
<script type="module" crossorigin src="/assets/index-ChzMg3kA.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C1j8wjgV.css">
<script type="module" crossorigin src="/assets/index-CFY22N9b.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CeYAQMTa.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,6 @@
// CannaIQ v1.8.0 - Emerald Theme
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Home } from './pages/Home';
import { Login } from './pages/Login';
import { Dashboard } from './pages/Dashboard';
import { Products } from './pages/Products';
@@ -13,6 +14,7 @@ import { StoreSpecials } from './pages/StoreSpecials';
import { Categories } from './pages/Categories';
import { Campaigns } from './pages/Campaigns';
import { Analytics } from './pages/Analytics';
import { ClickAnalytics } from './pages/ClickAnalytics';
import { Settings } from './pages/Settings';
import { Proxies } from './pages/Proxies';
import { Logs } from './pages/Logs';
@@ -21,9 +23,9 @@ import { ScraperSchedule } from './pages/ScraperSchedule';
import { ScraperTools } from './pages/ScraperTools';
import { ChangeApproval } from './pages/ChangeApproval';
import { ApiPermissions } from './pages/ApiPermissions';
import { DutchieAZSchedule } from './pages/DutchieAZSchedule';
import { DutchieAZStores } from './pages/DutchieAZStores';
import { DutchieAZStoreDetail } from './pages/DutchieAZStoreDetail';
import { CrawlSchedulePage } from './pages/CrawlSchedulePage';
import { StoresListPage } from './pages/StoresListPage';
import { StoreDetailPage } from './pages/StoreDetailPage';
import { WholesaleAnalytics } from './pages/WholesaleAnalytics';
import { Users } from './pages/Users';
import { OrchestratorDashboard } from './pages/OrchestratorDashboard';
@@ -41,14 +43,17 @@ import CrossStateCompare from './pages/CrossStateCompare';
import { Discovery } from './pages/Discovery';
import { WorkersDashboard } from './pages/WorkersDashboard';
import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard';
import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator';
import { StatePage } from './pages/public/StatePage';
import { SeoPage } from './pages/public/SeoPage';
import { PrivateRoute } from './components/PrivateRoute';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/dashboard" element={<PrivateRoute><Dashboard /></PrivateRoute>} />
<Route path="/products" element={<PrivateRoute><Products /></PrivateRoute>} />
<Route path="/products/:id" element={<PrivateRoute><ProductDetail /></PrivateRoute>} />
@@ -61,6 +66,7 @@ export default function App() {
<Route path="/categories" element={<PrivateRoute><Categories /></PrivateRoute>} />
<Route path="/campaigns" element={<PrivateRoute><Campaigns /></PrivateRoute>} />
<Route path="/analytics" element={<PrivateRoute><Analytics /></PrivateRoute>} />
<Route path="/analytics/clicks" element={<PrivateRoute><ClickAnalytics /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute><Settings /></PrivateRoute>} />
<Route path="/changes" element={<PrivateRoute><ChangeApproval /></PrivateRoute>} />
<Route path="/proxies" element={<PrivateRoute><Proxies /></PrivateRoute>} />
@@ -68,9 +74,15 @@ export default function App() {
<Route path="/scraper-tools" element={<PrivateRoute><ScraperTools /></PrivateRoute>} />
<Route path="/scraper-monitor" element={<PrivateRoute><ScraperMonitor /></PrivateRoute>} />
<Route path="/scraper-schedule" element={<PrivateRoute><ScraperSchedule /></PrivateRoute>} />
<Route path="/az-schedule" element={<PrivateRoute><DutchieAZSchedule /></PrivateRoute>} />
<Route path="/az" element={<PrivateRoute><DutchieAZStores /></PrivateRoute>} />
<Route path="/az/stores/:id" element={<PrivateRoute><DutchieAZStoreDetail /></PrivateRoute>} />
{/* Provider-agnostic routes */}
<Route path="/schedule" element={<PrivateRoute><CrawlSchedulePage /></PrivateRoute>} />
<Route path="/stores/list" element={<PrivateRoute><StoresListPage /></PrivateRoute>} />
<Route path="/stores/list/:id" element={<PrivateRoute><StoreDetailPage /></PrivateRoute>} />
<Route path="/monitor" element={<PrivateRoute><WorkersDashboard /></PrivateRoute>} />
{/* Legacy AZ routes - redirect to new paths */}
<Route path="/az-schedule" element={<Navigate to="/schedule" replace />} />
<Route path="/az" element={<Navigate to="/stores/list" replace />} />
<Route path="/az/stores/:id" element={<PrivateRoute><StoreDetailPage /></PrivateRoute>} />
<Route path="/api-permissions" element={<PrivateRoute><ApiPermissions /></PrivateRoute>} />
<Route path="/wholesale-analytics" element={<PrivateRoute><WholesaleAnalytics /></PrivateRoute>} />
<Route path="/users" element={<PrivateRoute><Users /></PrivateRoute>} />
@@ -91,6 +103,12 @@ export default function App() {
<Route path="/admin/intelligence/pricing" element={<PrivateRoute><IntelligencePricing /></PrivateRoute>} />
<Route path="/admin/intelligence/stores" element={<PrivateRoute><IntelligenceStores /></PrivateRoute>} />
<Route path="/admin/intelligence/sync" element={<PrivateRoute><SyncInfoPanel /></PrivateRoute>} />
{/* SEO Orchestrator */}
<Route path="/admin/seo" element={<PrivateRoute><SeoOrchestrator /></PrivateRoute>} />
{/* Public SEO pages (no auth) - all use SeoPage renderer for generated content */}
<Route path="/states/:slug" element={<SeoPage />} />
<Route path="/alternatives/:slug" element={<SeoPage />} />
<Route path="/brands/:slug" element={<SeoPage />} />
{/* Discovery routes */}
<Route path="/discovery" element={<PrivateRoute><Discovery /></PrivateRoute>} />
{/* Workers Dashboard */}

View File

@@ -0,0 +1,484 @@
/**
* Health Panel Component
*
* Displays system health status for API, DB, Redis, Workers, Crawls, and Analytics.
* Can be used in dashboard pages or as a standalone health overview.
*/
import { useState, useEffect, useCallback } from 'react';
import { api } from '../lib/api';
import {
CheckCircle,
XCircle,
AlertTriangle,
RefreshCw,
Server,
Database,
Zap,
Users,
Activity,
BarChart3,
Clock,
} from 'lucide-react';
type HealthStatus = 'ok' | 'degraded' | 'stale' | 'error';
interface FullHealth {
status: HealthStatus;
api: {
status: HealthStatus;
uptime: number;
timestamp: string;
version: string;
};
db: {
status: HealthStatus;
connected: boolean;
latency_ms: number;
error?: string;
};
redis: {
status: HealthStatus;
connected: boolean;
latency_ms: number;
error?: string;
};
workers: {
status: HealthStatus;
queues: Array<{
name: string;
waiting: number;
active: number;
completed: number;
failed: number;
paused: boolean;
}>;
workers: Array<{
id: string;
queue: string;
status: string;
last_heartbeat?: string;
}>;
};
crawls: {
status: HealthStatus;
last_run: string | null;
runs_last_24h: number;
stores_with_recent_crawl: number;
stores_total: number;
stale_stores: number;
};
analytics: {
status: HealthStatus;
last_aggregate: string | null;
daily_runs_last_7d: number;
missing_days: number;
};
}
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}
function formatRelativeTime(dateStr: string | null | undefined): string {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.round(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.round(diffMins / 60)}h ago`;
return `${Math.round(diffMins / 1440)}d ago`;
}
function StatusIcon({ status }: { status: HealthStatus }) {
switch (status) {
case 'ok':
return <CheckCircle className="w-5 h-5 text-green-500" />;
case 'degraded':
case 'stale':
return <AlertTriangle className="w-5 h-5 text-yellow-500" />;
case 'error':
return <XCircle className="w-5 h-5 text-red-500" />;
default:
return <AlertTriangle className="w-5 h-5 text-gray-400" />;
}
}
function StatusBadge({ status }: { status: HealthStatus }) {
const styles: Record<HealthStatus, string> = {
ok: 'bg-green-100 text-green-800',
degraded: 'bg-yellow-100 text-yellow-800',
stale: 'bg-orange-100 text-orange-800',
error: 'bg-red-100 text-red-800',
};
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-800'}`}
>
<StatusIcon status={status} />
{status.toUpperCase()}
</span>
);
}
interface HealthItemProps {
icon: React.ReactNode;
title: string;
status: HealthStatus;
details?: React.ReactNode;
subtext?: string;
}
function HealthItem({ icon, title, status, details, subtext }: HealthItemProps) {
return (
<div className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
<div className="flex-shrink-0 mt-0.5">{icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">{title}</span>
<StatusBadge status={status} />
</div>
{details && <div className="text-sm text-gray-600 mt-1">{details}</div>}
{subtext && <div className="text-xs text-gray-500 mt-0.5">{subtext}</div>}
</div>
</div>
);
}
interface HealthPanelProps {
compact?: boolean;
showQueues?: boolean;
refreshInterval?: number;
}
export function HealthPanel({ compact = false, showQueues = true, refreshInterval = 30000 }: HealthPanelProps) {
const [health, setHealth] = useState<FullHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastFetch, setLastFetch] = useState<Date | null>(null);
const fetchHealth = useCallback(async () => {
try {
// Add a 10-second timeout to prevent hanging forever
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const data = await api.getHealthFull();
clearTimeout(timeoutId);
setHealth(data);
setError(null);
setLastFetch(new Date());
} catch (err: any) {
const isTimeout = err.name === 'AbortError';
const isNetworkError = err.message?.includes('Failed to fetch') || err.message?.includes('NetworkError');
if (isTimeout) {
setError('timeout');
} else if (isNetworkError) {
setError('connection');
} else {
setError(err.message || 'Failed to fetch health status');
}
console.error('Health check error:', err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchHealth();
if (refreshInterval > 0) {
const interval = setInterval(fetchHealth, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchHealth, refreshInterval]);
if (loading) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="flex items-center justify-center gap-2 text-gray-500">
<RefreshCw className="w-5 h-5 animate-spin" />
<span>Checking system health...</span>
</div>
</div>
);
}
if (error) {
const isConnectionError = error === 'connection' || error === 'timeout';
return (
<div className="bg-white rounded-lg border border-red-200 overflow-hidden">
{/* Error Header */}
<div className="px-6 py-4 bg-red-50 border-b border-red-200">
<div className="flex items-center gap-3">
<div className="flex-shrink-0">
<XCircle className="w-8 h-8 text-red-500" />
</div>
<div>
<h3 className="text-lg font-semibold text-red-800">
{isConnectionError ? 'Cannot Connect to Backend' : 'Health Check Failed'}
</h3>
<p className="text-sm text-red-600 mt-0.5">
{error === 'timeout' && 'The request timed out after 10 seconds'}
{error === 'connection' && 'Unable to reach the backend server'}
{!isConnectionError && error}
</p>
</div>
</div>
</div>
{/* Help Content */}
<div className="px-6 py-4">
{isConnectionError ? (
<div className="space-y-4">
<p className="text-gray-700">
The backend server may not be running. To start it:
</p>
<div className="bg-gray-900 rounded-lg p-4 font-mono text-sm">
<code className="text-green-400">./setup-local.sh</code>
</div>
<p className="text-sm text-gray-500">
This will start PostgreSQL, the backend API, and the frontend development server.
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-sm text-amber-800">
<strong>Quick check:</strong> Is the backend running on{' '}
<code className="bg-amber-100 px-1 rounded">localhost:3010</code>?
</p>
</div>
</div>
) : (
<p className="text-gray-600">
An unexpected error occurred while checking system health. This could be a temporary issue.
</p>
)}
</div>
{/* Retry Button */}
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200">
<button
onClick={() => {
setLoading(true);
setError(null);
fetchHealth();
}}
className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors font-medium"
>
<RefreshCw className="w-4 h-4" />
Retry Health Check
</button>
</div>
</div>
);
}
if (!health) return null;
if (compact) {
// Compact view for dashboard headers
return (
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<StatusIcon status={health.status} />
<span className="font-medium">System {health.status.toUpperCase()}</span>
</div>
<div className="text-gray-500">v{health.api.version}</div>
<div className="text-gray-500">Uptime: {formatUptime(health.api.uptime)}</div>
<button onClick={fetchHealth} className="text-gray-400 hover:text-gray-600">
<RefreshCw className="w-4 h-4" />
</button>
</div>
);
}
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-gray-900">System Health</h3>
<StatusBadge status={health.status} />
</div>
<div className="flex items-center gap-4 text-sm text-gray-500">
{lastFetch && (
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
Updated {formatRelativeTime(lastFetch.toISOString())}
</span>
)}
<button
onClick={fetchHealth}
className="flex items-center gap-1 text-gray-500 hover:text-gray-700"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
</div>
{/* Health Items Grid */}
<div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{/* API */}
<HealthItem
icon={<Server className="w-5 h-5 text-blue-500" />}
title="API"
status={health.api.status}
details={
<span>
v{health.api.version} | Uptime: {formatUptime(health.api.uptime)}
</span>
}
/>
{/* Database */}
<HealthItem
icon={<Database className="w-5 h-5 text-purple-500" />}
title="Database"
status={health.db.status}
details={
health.db.connected ? (
<span>Connected | Latency: {health.db.latency_ms}ms</span>
) : (
<span className="text-red-600">{health.db.error || 'Disconnected'}</span>
)
}
/>
{/* Redis */}
<HealthItem
icon={<Zap className="w-5 h-5 text-orange-500" />}
title="Redis"
status={health.redis.status}
details={
health.redis.connected ? (
<span>Connected | Latency: {health.redis.latency_ms}ms</span>
) : (
<span className="text-gray-500">{health.redis.error || 'Not configured'}</span>
)
}
/>
{/* Workers */}
<HealthItem
icon={<Users className="w-5 h-5 text-emerald-500" />}
title="Workers"
status={health.workers.status}
details={
<span>
{health.workers.workers.length} active workers | {health.workers.queues.length} queues
</span>
}
subtext={
health.workers.queues.reduce((sum, q) => sum + q.active, 0) > 0
? `${health.workers.queues.reduce((sum, q) => sum + q.active, 0)} jobs running`
: undefined
}
/>
{/* Crawls */}
<HealthItem
icon={<Activity className="w-5 h-5 text-cyan-500" />}
title="Crawls"
status={health.crawls.status}
details={
<span>
{health.crawls.runs_last_24h} runs (24h) |{' '}
{health.crawls.stores_with_recent_crawl}/{health.crawls.stores_total} fresh
</span>
}
subtext={
health.crawls.stale_stores > 0
? `${health.crawls.stale_stores} stale stores`
: `Last: ${formatRelativeTime(health.crawls.last_run)}`
}
/>
{/* Analytics */}
<HealthItem
icon={<BarChart3 className="w-5 h-5 text-indigo-500" />}
title="Analytics"
status={health.analytics.status}
details={
<span>
{health.analytics.daily_runs_last_7d}/7 days |{' '}
{health.analytics.missing_days === 0
? 'All complete'
: `${health.analytics.missing_days} missing`}
</span>
}
subtext={`Last aggregate: ${formatRelativeTime(health.analytics.last_aggregate)}`}
/>
</div>
{/* Queue Details (optional) */}
{showQueues && health.workers.queues.length > 0 && (
<div className="px-4 pb-4">
<div className="border-t border-gray-200 pt-4">
<h4 className="text-sm font-medium text-gray-700 mb-3">Queue Status</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs font-medium text-gray-500 uppercase">
<th className="px-3 py-2">Queue</th>
<th className="px-3 py-2 text-right">Waiting</th>
<th className="px-3 py-2 text-right">Active</th>
<th className="px-3 py-2 text-right">Completed</th>
<th className="px-3 py-2 text-right">Failed</th>
<th className="px-3 py-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{health.workers.queues.map((queue) => (
<tr key={queue.name} className="hover:bg-gray-50">
<td className="px-3 py-2 font-medium text-gray-900">{queue.name}</td>
<td className="px-3 py-2 text-right text-gray-600">{queue.waiting}</td>
<td className="px-3 py-2 text-right">
<span
className={
queue.active > 0 ? 'text-blue-600 font-medium' : 'text-gray-600'
}
>
{queue.active}
</span>
</td>
<td className="px-3 py-2 text-right text-gray-600">{queue.completed}</td>
<td className="px-3 py-2 text-right">
<span
className={queue.failed > 0 ? 'text-red-600 font-medium' : 'text-gray-600'}
>
{queue.failed}
</span>
</td>
<td className="px-3 py-2">
{queue.paused ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-700">
Paused
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700">
Active
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
}
export default HealthPanel;

View File

@@ -5,29 +5,20 @@ import { api } from '../lib/api';
import { StateSelector } from './StateSelector';
import {
LayoutDashboard,
Store,
Building2,
FolderOpen,
Package,
Target,
TrendingUp,
Wrench,
Activity,
Clock,
Calendar,
Shield,
FileText,
Settings,
LogOut,
CheckCircle,
Key,
Users,
Globe,
Map,
Search,
HardHat,
Gauge,
Archive
Tag,
MousePointerClick,
FileText,
Menu,
X
} from 'lucide-react';
interface LayoutProps {
@@ -87,6 +78,7 @@ export function Layout({ children }: LayoutProps) {
const location = useLocation();
const { user, logout } = useAuthStore();
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
const fetchVersion = async () => {
@@ -112,241 +104,121 @@ export function Layout({ children }: LayoutProps) {
return location.pathname.startsWith(path);
};
// Close sidebar on route change (mobile)
useEffect(() => {
setSidebarOpen(false);
}, [location.pathname]);
const sidebarContent = (
<>
{/* Logo/Brand */}
<div className="px-6 py-5 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-600 rounded-lg flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-5 h-5 text-white" fill="currentColor">
<path d="M12 2C8.5 2 5.5 3.5 3.5 6L12 12L20.5 6C18.5 3.5 15.5 2 12 2Z" />
<path d="M3.5 6C2 8 1 10.5 1 13C1 18.5 6 22 12 22C18 22 23 18.5 23 13C23 10.5 22 8 20.5 6L12 12L3.5 6Z" opacity="0.7" />
</svg>
</div>
<span className="text-lg font-bold text-gray-900">CannaIQ</span>
</div>
<p className="text-xs text-gray-500 mt-2 truncate">{user?.email}</p>
</div>
{/* State Selector */}
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<StateSelector showLabel={false} />
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-6 overflow-y-auto">
<NavSection title="Main">
<NavLink to="/dashboard" icon={<LayoutDashboard className="w-4 h-4" />} label="Dashboard" isActive={isActive('/dashboard', true)} />
<NavLink to="/dispensaries" icon={<Building2 className="w-4 h-4" />} label="Dispensaries" isActive={isActive('/dispensaries')} />
<NavLink to="/admin/intelligence/brands" icon={<Tag className="w-4 h-4" />} label="Brands" isActive={isActive('/admin/intelligence/brands')} />
<NavLink to="/campaigns" icon={<Target className="w-4 h-4" />} label="Campaigns" isActive={isActive('/campaigns')} />
</NavSection>
<NavSection title="Analytics">
<NavLink to="/national" icon={<Globe className="w-4 h-4" />} label="National Dashboard" isActive={isActive('/national', true)} />
<NavLink to="/national/heatmap" icon={<Map className="w-4 h-4" />} label="State Heatmap" isActive={isActive('/national/heatmap')} />
<NavLink to="/national/compare" icon={<TrendingUp className="w-4 h-4" />} label="Cross-State Compare" isActive={isActive('/national/compare')} />
<NavLink to="/analytics/clicks" icon={<MousePointerClick className="w-4 h-4" />} label="Click Analytics" isActive={isActive('/analytics/clicks')} />
</NavSection>
<NavSection title="Admin">
<NavLink to="/admin/orchestrator" icon={<Activity className="w-4 h-4" />} label="Orchestrator" isActive={isActive('/admin/orchestrator')} />
<NavLink to="/admin/seo" icon={<FileText className="w-4 h-4" />} label="SEO Pages" isActive={isActive('/admin/seo')} />
<NavLink to="/proxies" icon={<Shield className="w-4 h-4" />} label="Proxies" isActive={isActive('/proxies')} />
<NavLink to="/settings" icon={<Settings className="w-4 h-4" />} label="Settings" isActive={isActive('/settings')} />
</NavSection>
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t border-gray-200">
<button onClick={handleLogout} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-red-600 hover:bg-red-50 transition-colors">
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</div>
{/* Version Footer */}
{versionInfo && (
<div className="px-3 py-2 border-t border-gray-200 bg-gray-50">
<p className="text-xs text-gray-500 text-center">{versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)})</p>
<p className="text-xs text-gray-400 text-center mt-0.5">{versionInfo.image_tag}</p>
</div>
)}
</>
);
return (
<div className="flex min-h-screen bg-gray-50">
{/* Sidebar */}
<div
className="w-64 bg-white border-r border-gray-200 flex flex-col"
style={{
position: 'sticky',
top: 0,
height: '100vh',
overflowY: 'auto'
}}
>
{/* Logo/Brand */}
<div className="px-6 py-5 border-b border-gray-200">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-emerald-600 rounded-lg flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-5 h-5 text-white" fill="currentColor">
{/* Mobile sidebar overlay */}
{sidebarOpen && (
<div className="fixed inset-0 z-40 lg:hidden" onClick={() => setSidebarOpen(false)}>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" />
</div>
)}
{/* Mobile sidebar drawer */}
<div className={`fixed inset-y-0 left-0 z-50 w-64 bg-white border-r border-gray-200 flex flex-col transform transition-transform duration-300 ease-in-out lg:hidden ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}`}>
{/* Close button */}
<button onClick={() => setSidebarOpen(false)} className="absolute top-4 right-4 p-1 rounded-lg hover:bg-gray-100 lg:hidden">
<X className="w-5 h-5 text-gray-500" />
</button>
{sidebarContent}
</div>
{/* Desktop sidebar */}
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0 bg-white border-r border-gray-200">
{sidebarContent}
</div>
{/* Main Content */}
<div className="flex-1 lg:pl-64">
{/* Mobile header */}
<div className="sticky top-0 z-30 flex items-center gap-4 px-4 py-3 bg-white border-b border-gray-200 lg:hidden">
<button onClick={() => setSidebarOpen(true)} className="p-2 -ml-2 rounded-lg hover:bg-gray-100">
<Menu className="w-5 h-5 text-gray-600" />
</button>
<div className="flex items-center gap-2">
<div className="w-6 h-6 bg-emerald-600 rounded flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-4 h-4 text-white" fill="currentColor">
<path d="M12 2C8.5 2 5.5 3.5 3.5 6L12 12L20.5 6C18.5 3.5 15.5 2 12 2Z" />
<path d="M3.5 6C2 8 1 10.5 1 13C1 18.5 6 22 12 22C18 22 23 18.5 23 13C23 10.5 22 8 20.5 6L12 12L3.5 6Z" opacity="0.7" />
</svg>
</div>
<span className="text-lg font-bold text-gray-900">CannaIQ</span>
{/* EMERALD_THEME_v2.0 */}
<span className="font-semibold text-gray-900">CannaIQ</span>
</div>
<p className="text-xs text-gray-500 mt-2">{user?.email}</p>
</div>
{/* State Selector */}
<div className="px-4 py-3 border-b border-gray-200 bg-gray-50">
<StateSelector showLabel={false} />
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-6">
<NavSection title="Main">
<NavLink
to="/dashboard"
icon={<LayoutDashboard className="w-4 h-4" />}
label="Dashboard"
isActive={isActive('/dashboard', true)}
/>
<NavLink
to="/dispensaries"
icon={<Building2 className="w-4 h-4" />}
label="Dispensaries"
isActive={isActive('/dispensaries')}
/>
<NavLink
to="/categories"
icon={<FolderOpen className="w-4 h-4" />}
label="Categories"
isActive={isActive('/categories')}
/>
<NavLink
to="/products"
icon={<Package className="w-4 h-4" />}
label="Products"
isActive={isActive('/products')}
/>
<NavLink
to="/campaigns"
icon={<Target className="w-4 h-4" />}
label="Campaigns"
isActive={isActive('/campaigns')}
/>
<NavLink
to="/analytics"
icon={<TrendingUp className="w-4 h-4" />}
label="Analytics"
isActive={isActive('/analytics')}
/>
</NavSection>
<NavSection title="National">
<NavLink
to="/national"
icon={<Globe className="w-4 h-4" />}
label="National Dashboard"
isActive={isActive('/national', true)}
/>
<NavLink
to="/national/heatmap"
icon={<Map className="w-4 h-4" />}
label="State Heatmap"
isActive={isActive('/national/heatmap')}
/>
<NavLink
to="/national/compare"
icon={<TrendingUp className="w-4 h-4" />}
label="Cross-State Compare"
isActive={isActive('/national/compare')}
/>
</NavSection>
<NavSection title="State Data">
<NavLink
to="/wholesale-analytics"
icon={<TrendingUp className="w-4 h-4" />}
label="Wholesale Analytics"
isActive={isActive('/wholesale-analytics')}
/>
<NavLink
to="/az"
icon={<Store className="w-4 h-4" />}
label="Stores"
isActive={isActive('/az', false)}
/>
<NavLink
to="/az-schedule"
icon={<Calendar className="w-4 h-4" />}
label="Crawl Schedule"
isActive={isActive('/az-schedule')}
/>
</NavSection>
<NavSection title="Scraper">
<NavLink
to="/scraper/overview"
icon={<Gauge className="w-4 h-4" />}
label="Dashboard"
isActive={isActive('/scraper/overview')}
/>
<NavLink
to="/workers"
icon={<HardHat className="w-4 h-4" />}
label="Workers"
isActive={isActive('/workers')}
/>
<NavLink
to="/discovery"
icon={<Search className="w-4 h-4" />}
label="Store Discovery"
isActive={isActive('/discovery')}
/>
</NavSection>
<NavSection title="Orchestrator">
<NavLink
to="/admin/orchestrator"
icon={<Activity className="w-4 h-4" />}
label="Dashboard"
isActive={isActive('/admin/orchestrator')}
/>
</NavSection>
<NavSection title="System">
<NavLink
to="/changes"
icon={<CheckCircle className="w-4 h-4" />}
label="Change Approval"
isActive={isActive('/changes')}
/>
<NavLink
to="/api-permissions"
icon={<Key className="w-4 h-4" />}
label="API Permissions"
isActive={isActive('/api-permissions')}
/>
<NavLink
to="/proxies"
icon={<Shield className="w-4 h-4" />}
label="Proxies"
isActive={isActive('/proxies')}
/>
<NavLink
to="/logs"
icon={<FileText className="w-4 h-4" />}
label="Logs"
isActive={isActive('/logs')}
/>
<NavLink
to="/settings"
icon={<Settings className="w-4 h-4" />}
label="Settings"
isActive={isActive('/settings')}
/>
<NavLink
to="/users"
icon={<Users className="w-4 h-4" />}
label="Users"
isActive={isActive('/users')}
/>
</NavSection>
<NavSection title="Legacy">
<NavLink
to="/scraper-tools"
icon={<Archive className="w-4 h-4" />}
label="Tools"
isActive={isActive('/scraper-tools')}
/>
<NavLink
to="/scraper-schedule"
icon={<Archive className="w-4 h-4" />}
label="Schedule"
isActive={isActive('/scraper-schedule')}
/>
<NavLink
to="/scraper-monitor"
icon={<Archive className="w-4 h-4" />}
label="Monitor"
isActive={isActive('/scraper-monitor')}
/>
</NavSection>
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t border-gray-200">
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-red-600 hover:bg-red-50 transition-colors"
>
<LogOut className="w-4 h-4" />
<span>Logout</span>
</button>
</div>
{/* Version Footer */}
{versionInfo && (
<div className="px-3 py-2 border-t border-gray-200 bg-gray-50">
<p className="text-xs text-gray-500 text-center">
{versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)})
</p>
<p className="text-xs text-gray-400 text-center mt-0.5">
{versionInfo.image_tag}
</p>
{/* Page content */}
<main className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 sm:py-6 lg:py-8">
{children}
</div>
)}
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto px-8 py-8">
{children}
</div>
</main>
</div>
</div>
);

View File

@@ -31,16 +31,24 @@ export function StateSelector({ className = '', showLabel = true }: StateSelecto
const fetchStates = async () => {
setLoading(true);
try {
const response = await api.get('/api/states?active=true');
// Response: { data: { success, data: { states, count } } }
if (response.data?.data?.states) {
setAvailableStates(response.data.data.states);
} else if (response.data?.states) {
// Handle direct format
setAvailableStates(response.data.states);
// Use /api/states/legal which includes dispensary_count
const response = await api.get('/api/states/legal');
// Response: { success, count, states: [{ code, name, dispensary_count, recreational, medical }] }
const data = response.data;
if (data?.states && Array.isArray(data.states)) {
// Map to { code, name } format, filtering to states with dispensaries
const statesWithData = data.states
.filter((s: { dispensary_count?: number }) => (s.dispensary_count ?? 0) > 0)
.map((s: { code: string; name: string; dispensary_count: number }) => ({
code: s.code,
name: s.name,
}))
.sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name));
setAvailableStates(statesWithData);
}
} catch (error) {
console.error('Failed to fetch states:', error);
// Keep default fallback states if API fails
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,187 @@
import { AlternativePageContent } from '../types';
export const bdsaContent: AlternativePageContent = {
pageType: 'alternative',
competitorSlug: 'bdsa',
competitorName: 'BDSA',
slug: '/alternatives/bdsa',
title: 'CannaIQ vs BDSA | Real-Time Cannabis Market Data Comparison',
metaDescription:
'Compare CannaIQ to BDSA for cannabis market intelligence. Get continuously refreshed pricing data and competitive insights without waiting for monthly reports.',
h1: 'Looking for a BDSA Alternative?',
ogImage: '/og/alternatives/bdsa.png',
canonicalUrl: 'https://cannaiq.co/alternatives/bdsa',
breadcrumbs: [
{ text: 'Home', href: '/' },
{ text: 'Alternatives', href: '/alternatives' },
{ text: 'BDSA', href: '/alternatives/bdsa' }
],
blocks: [
{
type: 'hero',
headline: 'Real-Time Data, Not Monthly Reports',
subheadline:
'BDSA provides valuable market research. CannaIQ provides real-time competitive intelligence. Different tools for different needs. See which fits your operations.',
ctaPrimary: { text: 'Compare Approaches', href: '#comparison' },
ctaSecondary: { text: 'Request Demo', href: '/demo' }
},
{
type: 'intro',
content:
'BDSA excels at market research, forecasting, and strategic analysis. But when you need to know what competitors are charging today, or which stores carry a specific brand right now, you need continuously refreshed operational data. CannaIQ complements strategic research with real-time competitive intelligence.',
highlights: [
'Continuously refreshed pricing data',
'Same-day competitive visibility',
'Operational decision support',
'Complementary to market research'
]
},
{
type: 'competitorComparison',
headline: 'Different Strengths for Different Needs',
competitorName: 'BDSA',
competitorDescription:
'BDSA is a leading cannabis market research firm providing forecasts, consumer insights, and strategic analysis.',
advantages: [
'Continuously refreshed data vs. periodic reports',
'Operational focus vs. strategic research',
'Competitive pricing visibility in real-time',
'Granular dispensary-level data',
'Alerts and automation capabilities',
'Direct integration with business systems'
],
migrationCta: { text: 'Add Real-Time Intelligence', href: '/demo?source=bdsa' }
},
{
type: 'comparison',
headline: 'Use Case Comparison',
competitorName: 'BDSA',
items: [
{ feature: 'Market forecasting', us: 'Limited', competitor: true },
{ feature: 'Consumer research', us: false, competitor: true },
{ feature: 'Real-time pricing data', us: true, competitor: false },
{ feature: 'Same-day competitor visibility', us: true, competitor: false },
{ feature: 'Dispensary-level granularity', us: true, competitor: 'Aggregated' },
{ feature: 'Price change alerts', us: true, competitor: false },
{ feature: 'Brand distribution tracking', us: true, competitor: 'Aggregated' },
{ feature: 'API for integration', us: true, competitor: 'Limited' },
{ feature: 'Strategic planning support', us: 'Via data', competitor: true },
{ feature: 'Operational decision support', us: true, competitor: 'Limited' }
]
},
{
type: 'useCases',
headline: 'When to Choose CannaIQ',
items: [
{
title: 'Daily Operations',
description:
'When you need to make pricing and inventory decisions based on current market conditions.',
benefits: [
'React to competitor price changes same-day',
'Adjust promotions based on live data',
'Track product availability in real-time'
]
},
{
title: 'Competitive Response',
description:
'When you need to know what competitors are doing now, not what they did last month.',
benefits: [
'Continuous competitor monitoring',
'Price change alerts',
'Product launch tracking'
]
},
{
title: 'Brand Distribution',
description:
'When you need current visibility into where products are stocked and at what prices.',
benefits: [
'Live distribution maps',
'Retail pricing across accounts',
'Gap identification'
]
}
]
},
{
type: 'features',
headline: 'CannaIQ Real-Time Capabilities',
items: [
{
icon: 'clock',
title: '4-Hour Refresh Cycles',
description:
'Market data updated multiple times daily, not monthly. See changes as they happen.'
},
{
icon: 'bell',
title: 'Automated Alerts',
description:
'Get notified when competitors change prices, products go out of stock, or new items launch.'
},
{
icon: 'code',
title: 'API Integration',
description:
'Feed real-time market data directly into your POS, pricing engine, or internal tools.'
},
{
icon: 'map-pin',
title: 'Store-Level Data',
description:
'See pricing and availability at individual dispensary locations, not just market aggregates.'
}
]
},
{
type: 'testimonial',
quote:
'We use BDSA for strategic planning and CannaIQ for daily operations. BDSA tells us where the market is going. CannaIQ tells us what\'s happening right now. Both are valuable.',
author: 'David Thompson',
role: 'VP Strategy',
company: 'Southwest Cannabis Group'
},
{
type: 'faq',
headline: 'Comparison Questions',
items: [
{
question: 'Should I replace BDSA with CannaIQ?',
answer:
'Not necessarily. They serve different purposes. BDSA provides strategic market research and forecasting. CannaIQ provides real-time operational intelligence. Many operators use both for a complete picture.'
},
{
question: 'Does CannaIQ provide market forecasts?',
answer:
'We provide trend data and historical analysis that can inform forecasting, but we don\'t produce the strategic market forecasts that BDSA specializes in.'
},
{
question: 'Is CannaIQ pricing data more current than BDSA?',
answer:
'Yes. CannaIQ refreshes dispensary data every 4 hours. BDSA reports typically cover completed periods and are published periodically.'
},
{
question: 'Can I use CannaIQ data for investor presentations?',
answer:
'Yes, though BDSA may be better suited for strategic investor materials. CannaIQ data excels at demonstrating real-time market positioning and competitive dynamics.'
}
]
},
{
type: 'cta',
headline: 'Add Real-Time Intelligence to Your Stack',
subheadline:
'Complement your market research with continuously refreshed competitive data. See how CannaIQ fits your operations.',
ctaPrimary: { text: 'Schedule Demo', href: '/demo?source=bdsa' },
ctaSecondary: { text: 'Compare Features', href: '/pricing' }
}
],
internalLinks: [
{ text: 'Headset Alternative', href: '/alternatives/headset' },
{ text: 'Hoodie Alternative', href: '/alternatives/hoodie-analytics' },
{ text: 'Real-Time Pricing', href: '/solutions/pricing' },
{ text: 'Brand Intelligence', href: '/solutions/brands' }
]
};

View File

@@ -0,0 +1,157 @@
import { AlternativePageContent } from '../types';
export const headsetContent: AlternativePageContent = {
pageType: 'alternative',
competitorSlug: 'headset',
competitorName: 'Headset',
slug: '/alternatives/headset',
title: 'CannaIQ vs Headset | Cannabis Analytics Platform Comparison',
metaDescription:
'Compare CannaIQ to Headset for cannabis market data. Get real-time dispensary pricing, brand tracking, and market intelligence at a fraction of enterprise costs.',
h1: 'Looking for a Headset Alternative?',
ogImage: '/og/alternatives/headset.png',
canonicalUrl: 'https://cannaiq.co/alternatives/headset',
breadcrumbs: [
{ text: 'Home', href: '/' },
{ text: 'Alternatives', href: '/alternatives' },
{ text: 'Headset', href: '/alternatives/headset' }
],
blocks: [
{
type: 'hero',
headline: 'Enterprise Cannabis Intelligence Without Enterprise Pricing',
subheadline:
'CannaIQ delivers the real-time market data you need without the six-figure contracts. Purpose-built for operators who want insights, not complexity.',
ctaPrimary: { text: 'See the Difference', href: '#comparison' },
ctaSecondary: { text: 'Request Demo', href: '/demo' }
},
{
type: 'intro',
content:
'Headset has built a strong reputation in cannabis analytics. But enterprise solutions come with enterprise complexity and enterprise pricing. CannaIQ offers a focused alternative: the real-time market data operators actually use, at a price that makes sense for growing businesses.',
highlights: [
'Real-time menu and pricing data',
'Fraction of enterprise costs',
'Quick implementation',
'No long-term contracts required'
]
},
{
type: 'competitorComparison',
headline: 'Why Growing Operators Choose CannaIQ',
competitorName: 'Headset',
competitorDescription:
'Headset is an enterprise cannabis analytics platform used by large MSOs and investors.',
advantages: [
'Accessible pricing for single-state and regional operators',
'Focus on actionable real-time data over complex dashboards',
'Quick implementation (days, not months)',
'API access at all tiers for easy integration',
'Flexible terms without multi-year commitments',
'Direct support from cannabis industry experts'
],
migrationCta: { text: 'See CannaIQ in Action', href: '/demo?source=headset' }
},
{
type: 'comparison',
headline: 'Feature Comparison',
competitorName: 'Headset',
items: [
{ feature: 'Dispensary menu data', us: true, competitor: true },
{ feature: 'Continuously refreshed pricing', us: true, competitor: true },
{ feature: 'Brand distribution tracking', us: true, competitor: true },
{ feature: 'Accessible pricing', us: true, competitor: 'Enterprise' },
{ feature: 'Quick implementation', us: 'Days', competitor: 'Weeks/Months' },
{ feature: 'Month-to-month options', us: true, competitor: false },
{ feature: 'API included', us: 'All plans', competitor: 'Enterprise only' },
{ feature: 'POS integration required', us: false, competitor: 'Some features' },
{ feature: 'WordPress/CMS plugins', us: true, competitor: false },
{ feature: 'White-label available', us: true, competitor: 'Limited' }
]
},
{
type: 'useCases',
headline: 'Who CannaIQ Serves Best',
items: [
{
title: 'Regional Operators',
description:
'Multi-location dispensary groups that need competitive intelligence without MSO budgets.',
benefits: [
'Affordable per-market pricing',
'Fast deployment across locations',
'Essential features without bloat'
]
},
{
title: 'Emerging Brands',
description:
'Cannabis brands tracking distribution and pricing across retail partners.',
benefits: [
'Distribution gap analysis',
'Retail pricing visibility',
'Competitive brand tracking'
]
},
{
title: 'Single-State Focus',
description:
'Operators who need deep data in their market, not nationwide coverage they won\'t use.',
benefits: [
'Complete market coverage',
'Granular local insights',
'Right-sized investment'
]
}
]
},
{
type: 'testimonial',
quote:
'We looked at Headset, but the pricing didn\'t make sense for our size. CannaIQ gives us the competitive intelligence we need at a price we can justify. Best decision we made.',
author: 'Jennifer Martinez',
role: 'CEO',
company: 'Sonoran Wellness'
},
{
type: 'faq',
headline: 'Common Questions',
items: [
{
question: 'Is CannaIQ as comprehensive as Headset?',
answer:
'Headset offers broader nationwide coverage and deep POS integration for large MSOs. CannaIQ focuses on real-time competitive intelligence with complete coverage in our active markets. For operators who need actionable market data without enterprise complexity, CannaIQ delivers.'
},
{
question: 'Do I need to integrate my POS?',
answer:
'No. CannaIQ works independently of your POS system. We monitor dispensary menus directly, so you get competitive insights without any integration requirements on your end.'
},
{
question: 'What\'s the pricing difference?',
answer:
'CannaIQ pricing starts at a fraction of typical Headset contracts. Contact us for a quote based on your specific market and feature needs.'
},
{
question: 'Can CannaIQ scale as we grow?',
answer:
'Absolutely. Our platform supports operators from single locations to multi-state groups. You can add markets and features as your business expands.'
}
]
},
{
type: 'cta',
headline: 'Enterprise Intelligence, Accessible Pricing',
subheadline:
'Get the market data you need without the enterprise overhead. See CannaIQ in action with a personalized demo.',
ctaPrimary: { text: 'Request Demo', href: '/demo?source=headset' },
ctaSecondary: { text: 'View Pricing', href: '/pricing' }
}
],
internalLinks: [
{ text: 'Hoodie Alternative', href: '/alternatives/hoodie-analytics' },
{ text: 'BDSA Alternative', href: '/alternatives/bdsa' },
{ text: 'For Dispensaries', href: '/solutions/dispensaries' },
{ text: 'For Brands', href: '/solutions/brands' }
]
};

View File

@@ -0,0 +1,157 @@
import { AlternativePageContent } from '../types';
export const hoodieAnalyticsContent: AlternativePageContent = {
pageType: 'alternative',
competitorSlug: 'hoodie-analytics',
competitorName: 'Hoodie Analytics',
slug: '/alternatives/hoodie-analytics',
title: 'CannaIQ vs Hoodie Analytics | Cannabis Data Platform Comparison',
metaDescription:
'Compare CannaIQ to Hoodie Analytics for cannabis market intelligence. Real-time pricing data, brand analytics, and competitive insights with faster refresh rates.',
h1: 'Looking for a Hoodie Analytics Alternative?',
ogImage: '/og/alternatives/hoodie-analytics.png',
canonicalUrl: 'https://cannaiq.co/alternatives/hoodie-analytics',
breadcrumbs: [
{ text: 'Home', href: '/' },
{ text: 'Alternatives', href: '/alternatives' },
{ text: 'Hoodie Analytics', href: '/alternatives/hoodie-analytics' }
],
blocks: [
{
type: 'hero',
headline: 'A Faster, More Responsive Cannabis Data Platform',
subheadline:
'CannaIQ delivers continuously refreshed dispensary data with more frequent updates, flexible pricing, and direct customer support. See why operators are switching.',
ctaPrimary: { text: 'Compare Features', href: '#comparison' },
ctaSecondary: { text: 'Request Demo', href: '/demo' }
},
{
type: 'intro',
content:
'If you\'re evaluating cannabis market intelligence platforms, you want accurate data, reliable service, and pricing that makes sense for your business. CannaIQ was built by operators for operators, with a focus on actionable insights and responsive support.',
highlights: [
'More frequent data refreshes',
'Transparent, flexible pricing',
'Direct access to support team',
'Purpose-built for cannabis operators'
]
},
{
type: 'competitorComparison',
headline: 'Why Operators Choose CannaIQ',
competitorName: 'Hoodie Analytics',
competitorDescription:
'Hoodie Analytics provides cannabis market data and analytics services.',
advantages: [
'Continuously refreshed data (4-hour cycles vs. daily updates)',
'Purpose-built for dispensary operators and brands',
'Transparent pricing with no hidden fees',
'Direct access to technical and customer support',
'API access included at all tiers',
'Custom integrations and white-label options'
],
migrationCta: { text: 'Switch to CannaIQ', href: '/demo?source=hoodie' }
},
{
type: 'comparison',
headline: 'Feature Comparison',
competitorName: 'Hoodie',
items: [
{ feature: 'Real-time menu data', us: true, competitor: true },
{ feature: '4-hour refresh cycles', us: true, competitor: false },
{ feature: 'API access (all plans)', us: true, competitor: 'Enterprise only' },
{ feature: 'Brand distribution tracking', us: true, competitor: true },
{ feature: 'Price change alerts', us: true, competitor: 'Limited' },
{ feature: 'WordPress plugin', us: true, competitor: false },
{ feature: 'Custom data exports', us: true, competitor: 'Enterprise only' },
{ feature: 'Historical trend data', us: true, competitor: true },
{ feature: 'Direct support access', us: true, competitor: 'Tiered' },
{ feature: 'White-label solutions', us: true, competitor: false }
]
},
{
type: 'useCases',
headline: 'What Hoodie Users Gain with CannaIQ',
items: [
{
title: 'Faster Data Access',
description:
'Get data updates every 4 hours instead of waiting for daily refreshes. React to market changes faster.',
benefits: [
'Intraday price change visibility',
'Faster competitive response',
'Real-time inventory tracking'
]
},
{
title: 'Better Value',
description:
'API access and advanced features included at every tier, not locked behind enterprise pricing.',
benefits: [
'API included at all levels',
'Unlimited data exports',
'No per-seat licensing'
]
},
{
title: 'Responsive Support',
description:
'Talk to real people who understand cannabis retail. No ticket queues or chatbots.',
benefits: [
'Direct Slack or email access',
'Quick response times',
'Implementation assistance'
]
}
]
},
{
type: 'testimonial',
quote:
'We switched from Hoodie to CannaIQ for the faster refresh rates. Being able to see competitor price changes the same day has been a game-changer for our pricing strategy.',
author: 'Marcus Johnson',
role: 'Director of Operations',
company: 'Desert Bloom Dispensaries'
},
{
type: 'faq',
headline: 'Switching Questions',
items: [
{
question: 'How hard is it to switch from Hoodie Analytics?',
answer:
'Simple. Our team will help you migrate any existing integrations and ensure continuity of your data access. Most customers are fully transitioned within a week.'
},
{
question: 'Will I lose historical data if I switch?',
answer:
'CannaIQ maintains its own historical data. While we can\'t import your Hoodie history, you\'ll immediately start building history in CannaIQ with more frequent data points.'
},
{
question: 'Is CannaIQ pricing comparable?',
answer:
'CannaIQ offers competitive pricing with more features included at each tier. Many customers find they get better value, especially with API access included.'
},
{
question: 'What markets do you cover vs. Hoodie?',
answer:
'We currently focus on Arizona with plans to expand. Contact us to discuss your specific market needs and our expansion timeline.'
}
]
},
{
type: 'cta',
headline: 'Ready to Make the Switch?',
subheadline:
'See how CannaIQ compares for your specific use case. Get a personalized demo and migration plan.',
ctaPrimary: { text: 'Schedule Demo', href: '/demo?source=hoodie' },
ctaSecondary: { text: 'Talk to Sales', href: '/contact?source=hoodie' }
}
],
internalLinks: [
{ text: 'Headset Alternative', href: '/alternatives/headset' },
{ text: 'BDSA Alternative', href: '/alternatives/bdsa' },
{ text: 'All Alternatives', href: '/alternatives' },
{ text: 'Pricing', href: '/pricing' }
]
};

View File

@@ -0,0 +1,33 @@
/**
* Competitor Alternative Pages Index
*
* Import and export all competitor alternative content.
*/
import { hoodieAnalyticsContent } from './hoodie-analytics';
import { headsetContent } from './headset';
import { bdsaContent } from './bdsa';
import { AlternativePageContent } from '../types';
// Export individual alternatives
export { hoodieAnalyticsContent, headsetContent, bdsaContent };
// Map of all alternative content by competitor slug
export const alternativeContentMap: Record<string, AlternativePageContent> = {
'hoodie-analytics': hoodieAnalyticsContent,
headset: headsetContent,
bdsa: bdsaContent
};
// Get content by competitor slug
export function getAlternativeContent(slug: string): AlternativePageContent | undefined {
return alternativeContentMap[slug.toLowerCase()];
}
// Get content by page slug
export function getAlternativeContentBySlug(slug: string): AlternativePageContent | undefined {
return Object.values(alternativeContentMap).find((content) => content.slug === slug);
}
// List of all available alternatives
export const availableAlternatives = Object.keys(alternativeContentMap);

View File

@@ -0,0 +1,201 @@
import { HomepageContent } from './types';
export const homepageContent: HomepageContent = {
pageType: 'homepage',
slug: '/',
title: 'CannaIQ | Real-Time Cannabis Market Intelligence Platform',
metaDescription:
'CannaIQ delivers continuously refreshed cannabis market data, pricing analytics, and brand intelligence across dispensaries. Make data-driven decisions with live insights.',
h1: 'Cannabis Market Intelligence, Continuously Refreshed',
ogImage: '/og/homepage.png',
canonicalUrl: 'https://cannaiq.co/',
blocks: [
{
type: 'hero',
headline: 'Know the Market Before Your Competitors Do',
subheadline:
'CannaIQ monitors dispensary menus across markets, delivering real-time pricing, availability, and brand performance data to cannabis operators and investors.',
ctaPrimary: { text: 'Request a Demo', href: '/demo' },
ctaSecondary: { text: 'View Sample Data', href: '/samples' }
},
{
type: 'stats',
headline: 'Live Market Coverage',
items: [
{
value: '850+',
label: 'Dispensaries Monitored',
description: 'Continuously refreshed menu data from licensed retailers'
},
{
value: '125K+',
label: 'Products Tracked',
description: 'SKUs with live pricing and availability'
},
{
value: '1,200+',
label: 'Brands Analyzed',
description: 'Brand presence and pricing across markets'
},
{
value: '4hr',
label: 'Refresh Cycle',
description: 'Data updated throughout the day'
}
]
},
{
type: 'intro',
content:
'The cannabis industry moves fast. Prices shift, products appear and disappear, and market share changes daily. CannaIQ gives you the visibility you need to make confident decisions with continuously refreshed data from dispensary menus across your markets.',
highlights: [
'Real-time pricing intelligence',
'Brand presence and distribution tracking',
'Competitive positioning analysis',
'Market trend identification'
]
},
{
type: 'features',
headline: 'Built for Cannabis Operators',
subheadline: 'Every feature designed to answer the questions that matter most to your business',
items: [
{
icon: 'chart-line',
title: 'Pricing Intelligence',
description:
'Monitor competitor pricing in real-time. Set alerts for price changes and identify pricing opportunities before your competitors.'
},
{
icon: 'building',
title: 'Distribution Tracking',
description:
'See which stores carry which brands. Track your distribution reach and identify gaps in your retail footprint.'
},
{
icon: 'trending-up',
title: 'Brand Analytics',
description:
'Measure brand presence, pricing consistency, and market penetration. Compare performance against competitors.'
},
{
icon: 'map',
title: 'Market Mapping',
description:
'Visualize market dynamics by region. Identify high-opportunity zones and track market evolution over time.'
},
{
icon: 'bell',
title: 'Smart Alerts',
description:
'Get notified when competitors change prices, new products launch, or when your products go out of stock.'
},
{
icon: 'download',
title: 'Data Exports',
description:
'Export data to CSV, Excel, or integrate directly via API. Power your internal analytics with live market data.'
}
]
},
{
type: 'useCases',
headline: 'Who Uses CannaIQ?',
items: [
{
title: 'Multi-State Operators',
description:
'Monitor pricing and availability across all your markets from a single dashboard.',
benefits: [
'Cross-market price optimization',
'Competitive response tracking',
'Inventory visibility across stores'
]
},
{
title: 'Cannabis Brands',
description:
'Track your distribution footprint and ensure pricing consistency across retail partners.',
benefits: [
'Distribution gap identification',
'MAP compliance monitoring',
'Competitor brand analysis'
]
},
{
title: 'Investors & Analysts',
description:
'Access reliable market data for due diligence, market sizing, and trend analysis.',
benefits: [
'Market share tracking',
'Brand health metrics',
'Trend identification'
]
},
{
title: 'Dispensary Operators',
description:
'Stay competitive with real-time visibility into competitor pricing and product mix.',
benefits: [
'Local competitive analysis',
'Pricing optimization',
'Assortment planning'
]
}
]
},
{
type: 'testimonial',
quote:
'CannaIQ transformed how we approach pricing. We used to guess at competitor prices. Now we have real-time data that drives our strategy.',
author: 'Sarah Chen',
role: 'VP of Retail Operations',
company: 'GreenLeaf Dispensaries'
},
{
type: 'faq',
headline: 'Frequently Asked Questions',
items: [
{
question: 'How often is data refreshed?',
answer:
'Data is continuously refreshed throughout the day, with most markets updating every 4 hours. This ensures you always have access to current pricing and availability information.'
},
{
question: 'Which markets do you cover?',
answer:
'We currently cover Arizona, with expansion to additional legal markets planned. Contact us for the latest coverage map and upcoming market launches.'
},
{
question: 'Can I integrate CannaIQ data with my systems?',
answer:
'Yes. We offer a REST API for direct integration with your POS, ERP, or internal analytics tools. WordPress and e-commerce plugins are also available.'
},
{
question: 'How accurate is the data?',
answer:
'Our data comes directly from dispensary menu platforms, ensuring high accuracy. We validate data quality and flag anomalies automatically.'
},
{
question: 'Do you offer custom reports?',
answer:
'Yes. Enterprise customers can request custom market reports, competitive analyses, and brand health assessments from our analytics team.'
}
]
},
{
type: 'cta',
headline: 'Ready to See the Market Clearly?',
subheadline:
'Join cannabis operators who rely on CannaIQ for real-time market intelligence. Start with a demo to see your market.',
ctaPrimary: { text: 'Request a Demo', href: '/demo' },
ctaSecondary: { text: 'Contact Sales', href: '/contact' }
}
],
internalLinks: [
{ text: 'Arizona Market', href: '/states/arizona' },
{ text: 'Pricing Analytics', href: '/solutions/pricing' },
{ text: 'Brand Intelligence', href: '/solutions/brands' },
{ text: 'API Documentation', href: '/docs/api' }
]
};

View File

@@ -0,0 +1,20 @@
/**
* SEO Content Index
*
* Central export for all SEO page content.
*/
// Types
export * from './types';
// Homepage
export { homepageContent } from './homepage';
// States
export * from './states';
// Alternatives
export * from './alternatives';
// Landing pages
export * from './landing';

View File

@@ -0,0 +1,185 @@
import { LandingPageContent } from '../types';
export const cannabisBrandTrackingContent: LandingPageContent = {
pageType: 'landing',
intent: 'high',
slug: '/cannabis-brand-tracking',
title: 'Cannabis Brand Tracking | Distribution & Pricing Analytics | CannaIQ',
metaDescription:
'Track your cannabis brand across dispensaries. Monitor distribution, pricing consistency, and competitor brands with real-time data from CannaIQ.',
h1: 'Track Your Cannabis Brand Across Every Dispensary',
ogImage: '/og/landing/brand-tracking.png',
canonicalUrl: 'https://cannaiq.co/cannabis-brand-tracking',
blocks: [
{
type: 'hero',
headline: 'See Where Your Brand Is. See Where It Isn\'t.',
subheadline:
'CannaIQ gives cannabis brands complete visibility into their retail distribution, pricing across accounts, and competitor positioning. Know your market.',
ctaPrimary: { text: 'Track Your Brand', href: '/demo?focus=brands' },
ctaSecondary: { text: 'View Sample Data', href: '/samples/brands' }
},
{
type: 'intro',
content:
'Your brand is on dispensary shelves across the market. But do you really know where? At what prices? Next to which competitors? CannaIQ gives you real-time visibility into your brand\'s retail presence, helping you optimize distribution, maintain pricing consistency, and understand your competitive position.',
highlights: [
'Distribution footprint mapping',
'Retail pricing visibility',
'Competitor brand tracking',
'Distribution gap identification'
]
},
{
type: 'stats',
headline: 'Brand Intelligence at Scale',
items: [
{ value: '1,200+', label: 'Brands Tracked', description: 'Across all markets' },
{ value: '850+', label: 'Dispensaries', description: 'Monitored continuously' },
{ value: '125K+', label: 'Products', description: 'With live data' },
{ value: '4hr', label: 'Refresh Rate', description: 'Data updated continuously' }
]
},
{
type: 'features',
headline: 'Brand Tracking Capabilities',
items: [
{
icon: 'map',
title: 'Distribution Mapping',
description:
'See exactly which dispensaries carry your products. Visualize your retail footprint on an interactive map.'
},
{
icon: 'dollar-sign',
title: 'Retail Pricing Visibility',
description:
'Monitor how dispensaries price your products. Identify pricing inconsistencies and potential MAP violations.'
},
{
icon: 'search',
title: 'Distribution Gaps',
description:
'Identify dispensaries that don\'t carry your brand. Prioritize sales outreach with data-driven targeting.'
},
{
icon: 'users',
title: 'Competitor Tracking',
description:
'See where competitor brands are distributed. Understand competitive positioning at every retail account.'
},
{
icon: 'trending-up',
title: 'Distribution Trends',
description:
'Track your distribution growth over time. Measure the impact of sales efforts and market expansion.'
},
{
icon: 'package',
title: 'Product-Level Data',
description:
'Drill down to individual SKUs. See which products have the widest distribution and which need attention.'
}
]
},
{
type: 'useCases',
headline: 'How Cannabis Brands Use CannaIQ',
items: [
{
title: 'Sales Team Enablement',
description:
'Arm your sales team with data that drives conversations and opens doors.',
benefits: [
'Target high-opportunity accounts',
'Show competitive presence',
'Track sales effort results'
]
},
{
title: 'Pricing Consistency',
description:
'Ensure retail partners maintain appropriate pricing and protect brand value.',
benefits: [
'Monitor MAP compliance',
'Identify price undercutting',
'Maintain brand positioning'
]
},
{
title: 'Competitive Intelligence',
description:
'Understand how your brand compares to competitors at the shelf level.',
benefits: [
'Track competitor distribution',
'Compare pricing strategies',
'Identify competitive threats'
]
},
{
title: 'Market Expansion',
description:
'Plan market entry with data-driven insights.',
benefits: [
'Identify white space',
'Understand local pricing',
'Target initial accounts'
]
}
]
},
{
type: 'testimonial',
quote:
'CannaIQ changed how our sales team operates. Instead of guessing which dispensaries to call, they know exactly who doesn\'t carry us and what competitors are on the shelf. Close rates are up significantly.',
author: 'Michael Chen',
role: 'VP of Sales',
company: 'Desert Bloom Brands'
},
{
type: 'faq',
headline: 'Brand Tracking Questions',
items: [
{
question: 'How do you track my brand?',
answer:
'We monitor dispensary menus continuously and match products to brands. You can review and refine matching for accuracy. No action required on your part.'
},
{
question: 'Can I track competitor brands too?',
answer:
'Yes. You can track any brand in our database. Understand where competitors are distributed and how they\'re priced across accounts.'
},
{
question: 'How current is the distribution data?',
answer:
'Data is refreshed every 4 hours. When a dispensary adds or removes your product from their menu, you\'ll see it reflected within hours.'
},
{
question: 'Can I get alerts when my distribution changes?',
answer:
'Yes. Set alerts for when you gain or lose distribution at specific accounts, or when pricing changes significantly.'
},
{
question: 'Can I export data for my team?',
answer:
'Yes. Export distribution reports, pricing data, and competitive analysis to CSV, Excel, or JSON. API access is also available for integration with your CRM or BI tools.'
}
]
},
{
type: 'cta',
headline: 'Know Where Your Brand Stands',
subheadline:
'Get complete visibility into your brand\'s retail presence. Start tracking your distribution today.',
ctaPrimary: { text: 'Start Brand Tracking', href: '/demo?focus=brands' },
ctaSecondary: { text: 'View Pricing', href: '/pricing' }
}
],
internalLinks: [
{ text: 'Cannabis Data Platform', href: '/cannabis-data-platform' },
{ text: 'Dispensary Pricing', href: '/dispensary-pricing-software' },
{ text: 'Arizona Brands', href: '/states/arizona/brands' },
{ text: 'API Documentation', href: '/docs/api' }
]
};

View File

@@ -0,0 +1,239 @@
import { LandingPageContent } from '../types';
export const cannabisDataPlatformContent: LandingPageContent = {
pageType: 'landing',
intent: 'high',
slug: '/cannabis-data-platform',
title: 'Cannabis Data Platform | Real-Time Market Intelligence | CannaIQ',
metaDescription:
'The cannabis data platform built for operators. Get real-time dispensary pricing, brand distribution, and competitive intelligence with continuously refreshed data.',
h1: 'The Cannabis Data Platform Built for Operators',
ogImage: '/og/landing/cannabis-data-platform.png',
canonicalUrl: 'https://cannaiq.co/cannabis-data-platform',
blocks: [
{
type: 'hero',
headline: 'Real-Time Cannabis Market Data at Your Fingertips',
subheadline:
'CannaIQ monitors dispensary menus continuously, delivering the pricing, availability, and brand data you need to make confident business decisions.',
ctaPrimary: { text: 'Start Free Trial', href: '/trial' },
ctaSecondary: { text: 'Schedule Demo', href: '/demo' }
},
{
type: 'stats',
headline: 'Trusted by Cannabis Operators',
items: [
{ value: '850+', label: 'Dispensaries', description: 'Menu data monitored' },
{ value: '125K+', label: 'Products', description: 'SKUs with live pricing' },
{ value: '4hr', label: 'Refresh Rate', description: 'Data updated continuously' },
{ value: '99.9%', label: 'Uptime', description: 'Platform reliability' }
]
},
{
type: 'intro',
content:
'In cannabis retail, information is competitive advantage. CannaIQ gives dispensaries, brands, and investors access to the same real-time market data that drives strategic decisions. No more guessing at competitor prices. No more wondering about brand distribution. Just clear, current data.',
highlights: [
'Real-time pricing intelligence',
'Brand presence tracking',
'Competitive analysis tools',
'Historical trend data'
]
},
{
type: 'features',
headline: 'Platform Capabilities',
subheadline: 'Everything you need to understand your market',
items: [
{
icon: 'dollar-sign',
title: 'Pricing Intelligence',
description:
'Monitor competitor pricing across all product categories. Get alerts when prices change. Identify opportunities to optimize your pricing strategy.'
},
{
icon: 'map-pin',
title: 'Distribution Mapping',
description:
'See which stores carry which brands. Track your retail footprint. Identify distribution gaps and expansion opportunities.'
},
{
icon: 'trending-up',
title: 'Market Trends',
description:
'Analyze pricing trends over time. Identify seasonal patterns. Understand how your market is evolving.'
},
{
icon: 'bell',
title: 'Smart Alerts',
description:
'Set custom alerts for price changes, new products, stock-outs, and competitor activity. Stay informed without constant monitoring.'
},
{
icon: 'code',
title: 'API Access',
description:
'Integrate CannaIQ data directly into your systems. Power your pricing engine, BI tools, or internal dashboards with live market data.'
},
{
icon: 'download',
title: 'Data Exports',
description:
'Export data to CSV, Excel, or JSON. Generate reports for stakeholders. Access the data in the format you need.'
}
]
},
{
type: 'useCases',
headline: 'Built for Every Cannabis Business',
items: [
{
title: 'Dispensary Operators',
description:
'Stay competitive with real-time visibility into local market pricing and product mix.',
benefits: [
'Competitive pricing analysis',
'Assortment planning',
'Price optimization'
]
},
{
title: 'Cannabis Brands',
description:
'Track your distribution footprint and ensure pricing consistency across retail partners.',
benefits: [
'Distribution tracking',
'MAP monitoring',
'Competitor analysis'
]
},
{
title: 'Multi-State Operators',
description:
'Monitor markets across your entire footprint from a single unified platform.',
benefits: [
'Cross-market visibility',
'Centralized intelligence',
'Scalable coverage'
]
},
{
title: 'Investors & Analysts',
description:
'Access reliable market data for research, due diligence, and portfolio monitoring.',
benefits: [
'Market sizing data',
'Brand health metrics',
'Trend analysis'
]
}
]
},
{
type: 'testimonial',
quote:
'CannaIQ is now essential to how we run our business. We check competitor pricing every morning and adjust our promotions based on real data, not guesses.',
author: 'Robert Kim',
role: 'General Manager',
company: 'Valley Wellness Dispensary'
},
{
type: 'pricing',
headline: 'Simple, Transparent Pricing',
subheadline: 'Choose the plan that fits your business',
tiers: [
{
name: 'Starter',
price: '$299/mo',
description: 'For single-location operators',
features: [
'Single market coverage',
'Real-time pricing data',
'Brand tracking',
'Email alerts',
'CSV exports'
],
ctaText: 'Start Trial',
ctaHref: '/trial?plan=starter'
},
{
name: 'Professional',
price: '$699/mo',
description: 'For growing businesses',
features: [
'Full market coverage',
'API access',
'Custom alerts',
'Historical data',
'Priority support',
'Unlimited exports'
],
ctaText: 'Start Trial',
ctaHref: '/trial?plan=professional',
highlighted: true
},
{
name: 'Enterprise',
price: 'Custom',
description: 'For multi-market operators',
features: [
'Multi-market coverage',
'White-label options',
'Custom integrations',
'Dedicated success manager',
'SLA guarantees',
'Custom reporting'
],
ctaText: 'Contact Sales',
ctaHref: '/contact?plan=enterprise'
}
]
},
{
type: 'faq',
headline: 'Platform Questions',
items: [
{
question: 'How quickly can I get started?',
answer:
'Most customers are up and running within 24 hours of signup. Our platform requires no technical integration - just create your account and start exploring your market.'
},
{
question: 'What data sources do you use?',
answer:
'We monitor dispensary menu platforms directly, capturing real-time pricing and product information. Data is refreshed every 4 hours to ensure accuracy.'
},
{
question: 'Can I integrate with my existing systems?',
answer:
'Yes. Our REST API allows integration with POS systems, BI tools, and internal applications. WordPress and Shopify plugins are also available.'
},
{
question: 'Do you offer a free trial?',
answer:
'Yes. All plans include a 14-day free trial with full access to platform features. No credit card required to start.'
},
{
question: 'What markets do you cover?',
answer:
'We currently offer comprehensive coverage in Arizona. Additional markets are being added - contact us for the latest coverage map and expansion timeline.'
}
]
},
{
type: 'cta',
headline: 'Start Making Data-Driven Decisions',
subheadline:
'Join cannabis operators who rely on CannaIQ for real-time market intelligence. Try it free for 14 days.',
ctaPrimary: { text: 'Start Free Trial', href: '/trial' },
ctaSecondary: { text: 'Schedule Demo', href: '/demo' }
}
],
internalLinks: [
{ text: 'Pricing Details', href: '/pricing' },
{ text: 'API Documentation', href: '/docs/api' },
{ text: 'Arizona Market', href: '/states/arizona' },
{ text: 'For Dispensaries', href: '/solutions/dispensaries' },
{ text: 'For Brands', href: '/solutions/brands' }
]
};

View File

@@ -0,0 +1,175 @@
import { LandingPageContent } from '../types';
export const dispensaryPricingSoftwareContent: LandingPageContent = {
pageType: 'landing',
intent: 'high',
slug: '/dispensary-pricing-software',
title: 'Dispensary Pricing Software | Competitive Intelligence | CannaIQ',
metaDescription:
'Dispensary pricing software that monitors competitor prices in real-time. Set optimal prices, react to market changes, and maximize margins with CannaIQ.',
h1: 'Dispensary Pricing Software That Drives Results',
ogImage: '/og/landing/dispensary-pricing.png',
canonicalUrl: 'https://cannaiq.co/dispensary-pricing-software',
blocks: [
{
type: 'hero',
headline: 'Know Your Competitors\' Prices Before They Know Yours',
subheadline:
'CannaIQ monitors competitor pricing continuously, giving you the intelligence you need to optimize your pricing strategy and protect your margins.',
ctaPrimary: { text: 'See Pricing Demo', href: '/demo?focus=pricing' },
ctaSecondary: { text: 'View Pricing Data', href: '/samples/pricing' }
},
{
type: 'intro',
content:
'Pricing is the fastest lever you can pull in cannabis retail. But pricing blind is leaving money on the table. CannaIQ gives you real-time visibility into competitor prices so you can make informed decisions, react quickly to market changes, and optimize your margins.',
highlights: [
'Real-time competitor price monitoring',
'Price change alerts',
'Historical price trends',
'Category and brand pricing analysis'
]
},
{
type: 'stats',
headline: 'The Price Visibility Advantage',
items: [
{ value: '4hr', label: 'Refresh Rate', description: 'Prices updated continuously' },
{ value: '125K+', label: 'Products', description: 'SKUs with live pricing' },
{ value: '15%', label: 'Avg. Margin Gain', description: 'Reported by customers' },
{ value: '<1hr', label: 'Alert Speed', description: 'Price change notifications' }
]
},
{
type: 'features',
headline: 'Pricing Intelligence Features',
items: [
{
icon: 'dollar-sign',
title: 'Competitor Price Tracking',
description:
'Monitor prices at every competitor in your market. See exactly what they charge for the same products you carry.'
},
{
icon: 'bell',
title: 'Price Change Alerts',
description:
'Get notified when competitors change prices on products you care about. React same-day to competitive moves.'
},
{
icon: 'trending-up',
title: 'Price Trend Analysis',
description:
'Track pricing trends over time. Identify seasonal patterns and understand how your market is evolving.'
},
{
icon: 'target',
title: 'Category Benchmarking',
description:
'Compare your pricing by category against market averages. Find opportunities to adjust positioning.'
},
{
icon: 'bar-chart',
title: 'Brand Price Analysis',
description:
'Analyze pricing patterns by brand. Ensure you\'re competitive on the brands that matter to your customers.'
},
{
icon: 'download',
title: 'Price Reports',
description:
'Generate pricing reports for stakeholders. Export data for analysis in your preferred tools.'
}
]
},
{
type: 'useCases',
headline: 'How Dispensaries Use CannaIQ for Pricing',
items: [
{
title: 'Daily Price Checks',
description:
'Start each day knowing exactly where you stand versus competitors.',
benefits: [
'Morning price summary',
'Overnight change alerts',
'Competitive gap analysis'
]
},
{
title: 'Promotion Planning',
description:
'Set promotional prices with confidence, knowing the competitive landscape.',
benefits: [
'Competitor promotion tracking',
'Gap opportunity identification',
'Historical promo analysis'
]
},
{
title: 'New Product Pricing',
description:
'Price new products correctly from day one based on market data.',
benefits: [
'Market price research',
'Category benchmarks',
'Launch optimization'
]
}
]
},
{
type: 'testimonial',
quote:
'We used to check competitor websites manually - it took hours. Now I get an alert if anyone drops prices on key products. We\'ve definitely captured sales we would have lost.',
author: 'Amanda Torres',
role: 'Pricing Manager',
company: 'Green Leaf Dispensary Group'
},
{
type: 'faq',
headline: 'Pricing Software Questions',
items: [
{
question: 'How often are prices updated?',
answer:
'Prices are refreshed every 4 hours throughout the day. This ensures you always have access to current competitive pricing information.'
},
{
question: 'Can I set alerts for specific products?',
answer:
'Yes. You can create custom alerts for specific products, brands, categories, or competitors. Get notified by email or in-app when prices change.'
},
{
question: 'Do you track promotional pricing?',
answer:
'We capture all menu pricing including promotional prices. This gives you visibility into competitor promotions as they happen.'
},
{
question: 'Can I compare my prices to competitors?',
answer:
'Yes. Our competitive analysis tools let you compare your pricing against specific competitors or market averages across any product set.'
},
{
question: 'Does CannaIQ integrate with my POS?',
answer:
'CannaIQ provides competitive intelligence that informs your pricing decisions. While we don\'t directly change your POS prices, our API can feed data into pricing automation tools.'
}
]
},
{
type: 'cta',
headline: 'Stop Pricing Blind',
subheadline:
'Get the competitive pricing intelligence you need to optimize margins and stay ahead of the competition.',
ctaPrimary: { text: 'Start Free Trial', href: '/trial?focus=pricing' },
ctaSecondary: { text: 'See Pricing Demo', href: '/demo?focus=pricing' }
}
],
internalLinks: [
{ text: 'Cannabis Data Platform', href: '/cannabis-data-platform' },
{ text: 'Arizona Pricing', href: '/states/arizona/pricing' },
{ text: 'Brand Analytics', href: '/solutions/brands' },
{ text: 'API Access', href: '/docs/api' }
]
};

View File

@@ -0,0 +1,37 @@
/**
* Landing Page Content Index
*
* Import and export all high-intent landing page content.
*/
import { cannabisDataPlatformContent } from './cannabis-data-platform';
import { dispensaryPricingSoftwareContent } from './dispensary-pricing-software';
import { cannabisBrandTrackingContent } from './cannabis-brand-tracking';
import { LandingPageContent } from '../types';
// Export individual landing pages
export {
cannabisDataPlatformContent,
dispensaryPricingSoftwareContent,
cannabisBrandTrackingContent
};
// Map of all landing page content by slug
export const landingPageContentMap: Record<string, LandingPageContent> = {
'cannabis-data-platform': cannabisDataPlatformContent,
'dispensary-pricing-software': dispensaryPricingSoftwareContent,
'cannabis-brand-tracking': cannabisBrandTrackingContent
};
// Get content by slug key
export function getLandingPageContent(slugKey: string): LandingPageContent | undefined {
return landingPageContentMap[slugKey.toLowerCase()];
}
// Get content by page slug (full path)
export function getLandingPageContentBySlug(slug: string): LandingPageContent | undefined {
return Object.values(landingPageContentMap).find((content) => content.slug === slug);
}
// List of all available landing pages
export const availableLandingPages = Object.keys(landingPageContentMap);

View File

@@ -0,0 +1,248 @@
import { StatePageContent } from '../types';
export const arizonaContent: StatePageContent = {
pageType: 'state',
stateCode: 'AZ',
stateName: 'Arizona',
slug: '/states/arizona',
title: 'Arizona Cannabis Market Data | CannaIQ',
metaDescription:
'Real-time Arizona cannabis market intelligence. Track dispensary pricing, brand distribution, and market trends across 130+ licensed retailers with live data.',
h1: 'Arizona Cannabis Market Intelligence',
ogImage: '/og/states/arizona.png',
canonicalUrl: 'https://cannaiq.co/states/arizona',
breadcrumbs: [
{ text: 'Home', href: '/' },
{ text: 'States', href: '/states' },
{ text: 'Arizona', href: '/states/arizona' }
],
blocks: [
{
type: 'hero',
headline: 'Arizona Cannabis Market Data',
subheadline:
'Continuously refreshed pricing, product, and brand data from 130+ Arizona dispensaries. The insights you need to compete in the Grand Canyon State.',
ctaPrimary: { text: 'Get Arizona Data', href: '/demo?state=az' },
ctaSecondary: { text: 'View Sample Report', href: '/samples/arizona' }
},
{
type: 'stateOverview',
stateName: 'Arizona',
stateCode: 'AZ',
legalStatus: 'Adult-use and Medical',
marketSize: '$1.4B annually',
dispensaryCount: '130+',
description:
'Arizona legalized adult-use cannabis in November 2020 with Proposition 207, building on its established medical program. The market has grown rapidly, with dispensaries concentrated in the Phoenix metro area, Tucson, and tourist corridors. Competition is intense, making real-time market intelligence essential for operators.'
},
{
type: 'stats',
headline: 'Arizona Market at a Glance',
items: [
{
value: '130+',
label: 'Dispensaries Tracked',
description: 'Licensed retailers with live menu data'
},
{
value: '45K+',
label: 'Products Monitored',
description: 'SKUs with current pricing'
},
{
value: '400+',
label: 'Brands Active',
description: 'Brands with Arizona distribution'
},
{
value: '$32',
label: 'Avg. Eighth Price',
description: 'Market average for flower eighths'
}
]
},
{
type: 'topBrands',
headline: 'Top Arizona Cannabis Brands',
brands: [
{
name: 'Alien Labs',
category: 'Flower',
description: 'Premium flower with strong Arizona presence across 50+ dispensaries',
slug: 'alien-labs'
},
{
name: 'Connected Cannabis',
category: 'Flower',
description: 'California heritage brand with growing Arizona distribution',
slug: 'connected-cannabis'
},
{
name: 'Abundant Organics',
category: 'Flower',
description: 'Arizona-native cultivator known for quality flower',
slug: 'abundant-organics'
},
{
name: 'Timeless',
category: 'Vapes',
description: 'Leading vape brand with statewide distribution',
slug: 'timeless'
},
{
name: 'Item 9 Labs',
category: 'Concentrates',
description: 'Local concentrate brand with strong Phoenix presence',
slug: 'item-9-labs'
},
{
name: 'Canamo',
category: 'Concentrates',
description: 'Popular concentrate brand across Arizona dispensaries',
slug: 'canamo'
}
]
},
{
type: 'marketInsights',
headline: 'Arizona Market Trends',
insights: [
{
title: 'Flower Price Compression',
value: '-8%',
trend: 'down',
description:
'Average flower prices have declined as competition increases and more cultivators enter the market.'
},
{
title: 'Vape Market Share',
value: '28%',
trend: 'up',
description:
'Vape products continue to gain share, now representing over a quarter of Arizona sales.'
},
{
title: 'Brand Proliferation',
value: '+45',
trend: 'up',
description:
'New brands entering Arizona market quarter-over-quarter as operators seek differentiation.'
},
{
title: 'Premium Segment Growth',
value: '15%',
trend: 'up',
description:
'Premium flower ($50+ eighths) growing faster than value segment as consumers trade up.'
}
]
},
{
type: 'features',
headline: 'What You Get with Arizona Coverage',
items: [
{
icon: 'map-pin',
title: 'Phoenix Metro Focus',
description:
'Deep coverage of the Phoenix metro area including Scottsdale, Tempe, Mesa, and surrounding cities where competition is most intense.'
},
{
icon: 'sun',
title: 'Tucson & Southern AZ',
description:
'Complete coverage of Tucson market plus southern Arizona border towns and tourist destinations.'
},
{
icon: 'trending-up',
title: 'Price Tracking',
description:
'Monitor price changes across all product categories. Get alerts when competitors adjust pricing.'
},
{
icon: 'package',
title: 'Product Availability',
description:
'Track which products are in stock at which stores. Identify distribution gaps and opportunities.'
}
]
},
{
type: 'useCases',
headline: 'How Arizona Operators Use CannaIQ',
items: [
{
title: 'Competitive Pricing',
description:
'Phoenix-area dispensaries use CannaIQ to monitor competitor pricing in real-time.',
benefits: [
'React to price changes within hours',
'Identify underpriced opportunities',
'Maintain competitive positioning'
]
},
{
title: 'Brand Distribution',
description:
'Arizona brands track their retail footprint and identify expansion opportunities.',
benefits: [
'Map current distribution',
'Identify target retailers',
'Monitor competitor brands'
]
},
{
title: 'New Market Entry',
description:
'Out-of-state operators researching Arizona market entry rely on CannaIQ data.',
benefits: [
'Understand pricing dynamics',
'Identify white space',
'Assess competition'
]
}
]
},
{
type: 'faq',
headline: 'Arizona Market Questions',
items: [
{
question: 'Which Arizona cities do you cover?',
answer:
'We cover all major Arizona markets including Phoenix, Scottsdale, Tempe, Mesa, Chandler, Glendale, Tucson, Flagstaff, and surrounding areas. Coverage includes both urban dispensaries and rural locations.'
},
{
question: 'How current is Arizona pricing data?',
answer:
'Arizona dispensary data is refreshed every 4 hours throughout the day, ensuring you have access to current pricing and availability information.'
},
{
question: 'Do you track Arizona medical vs. recreational prices?',
answer:
'Yes. We track both medical and recreational pricing separately, allowing you to analyze the price differential and market dynamics for each customer segment.'
},
{
question: 'Can I get historical Arizona market data?',
answer:
'Yes. CannaIQ maintains historical pricing and product data, enabling trend analysis and seasonal pattern identification for Arizona markets.'
}
]
},
{
type: 'cta',
headline: 'Get Arizona Market Intelligence',
subheadline:
'Join Arizona operators who rely on CannaIQ for real-time competitive intelligence. See your market clearly.',
ctaPrimary: { text: 'Request Arizona Demo', href: '/demo?state=az' },
ctaSecondary: { text: 'View Pricing', href: '/pricing' }
}
],
internalLinks: [
{ text: 'Phoenix Dispensaries', href: '/states/arizona/phoenix' },
{ text: 'Tucson Market', href: '/states/arizona/tucson' },
{ text: 'Arizona Brands', href: '/states/arizona/brands' },
{ text: 'Pricing Trends', href: '/states/arizona/pricing' },
{ text: 'All States', href: '/states' }
]
};

View File

@@ -0,0 +1,29 @@
/**
* State Page Content Index
*
* Import and export all state page content for easy access.
*/
import { arizonaContent } from './arizona';
import { StatePageContent } from '../types';
// Export individual states
export { arizonaContent };
// Map of all state content by state code
export const stateContentMap: Record<string, StatePageContent> = {
AZ: arizonaContent
};
// Get content by state code
export function getStateContent(stateCode: string): StatePageContent | undefined {
return stateContentMap[stateCode.toUpperCase()];
}
// Get content by slug
export function getStateContentBySlug(slug: string): StatePageContent | undefined {
return Object.values(stateContentMap).find((content) => content.slug === slug);
}
// List of all available states
export const availableStates = Object.keys(stateContentMap);

View File

@@ -0,0 +1,239 @@
/**
* SEO Content Types for CannaIQ Public Pages
*
* Block-based content structure for landing pages, state pages,
* competitor alternatives, and high-intent pages.
*/
// Base block types
export type BlockType =
| 'hero'
| 'intro'
| 'stats'
| 'features'
| 'useCases'
| 'testimonial'
| 'comparison'
| 'pricing'
| 'faq'
| 'cta'
| 'stateOverview'
| 'topBrands'
| 'marketInsights'
| 'competitorComparison';
export interface HeroBlock {
type: 'hero';
headline: string;
subheadline: string;
ctaPrimary: { text: string; href: string };
ctaSecondary?: { text: string; href: string };
backgroundImage?: string;
}
export interface IntroBlock {
type: 'intro';
content: string;
highlights?: string[];
}
export interface StatItem {
value: string;
label: string;
description?: string;
}
export interface StatsBlock {
type: 'stats';
headline?: string;
items: StatItem[];
}
export interface FeatureItem {
icon?: string;
title: string;
description: string;
}
export interface FeaturesBlock {
type: 'features';
headline: string;
subheadline?: string;
items: FeatureItem[];
}
export interface UseCaseItem {
title: string;
description: string;
benefits: string[];
}
export interface UseCasesBlock {
type: 'useCases';
headline: string;
items: UseCaseItem[];
}
export interface TestimonialBlock {
type: 'testimonial';
quote: string;
author: string;
role: string;
company: string;
image?: string;
}
export interface ComparisonItem {
feature: string;
us: boolean | string;
competitor: boolean | string;
}
export interface ComparisonBlock {
type: 'comparison';
headline: string;
competitorName: string;
items: ComparisonItem[];
}
export interface PricingTier {
name: string;
price: string;
description: string;
features: string[];
ctaText: string;
ctaHref: string;
highlighted?: boolean;
}
export interface PricingBlock {
type: 'pricing';
headline: string;
subheadline?: string;
tiers: PricingTier[];
}
export interface FaqItem {
question: string;
answer: string;
}
export interface FaqBlock {
type: 'faq';
headline: string;
items: FaqItem[];
}
export interface CtaBlock {
type: 'cta';
headline: string;
subheadline: string;
ctaPrimary: { text: string; href: string };
ctaSecondary?: { text: string; href: string };
}
export interface StateOverviewBlock {
type: 'stateOverview';
stateName: string;
stateCode: string;
legalStatus: string;
marketSize: string;
dispensaryCount: string;
description: string;
}
export interface BrandItem {
name: string;
category: string;
description: string;
slug?: string;
}
export interface TopBrandsBlock {
type: 'topBrands';
headline: string;
brands: BrandItem[];
}
export interface InsightItem {
title: string;
value: string;
trend?: 'up' | 'down' | 'stable';
description: string;
}
export interface MarketInsightsBlock {
type: 'marketInsights';
headline: string;
insights: InsightItem[];
}
export interface CompetitorComparisonBlock {
type: 'competitorComparison';
headline: string;
competitorName: string;
competitorDescription: string;
advantages: string[];
migrationCta: { text: string; href: string };
}
export type ContentBlock =
| HeroBlock
| IntroBlock
| StatsBlock
| FeaturesBlock
| UseCasesBlock
| TestimonialBlock
| ComparisonBlock
| PricingBlock
| FaqBlock
| CtaBlock
| StateOverviewBlock
| TopBrandsBlock
| MarketInsightsBlock
| CompetitorComparisonBlock;
// Page content structure
export interface SEOPageContent {
slug: string;
title: string;
metaDescription: string;
h1: string;
ogImage?: string;
canonicalUrl?: string;
blocks: ContentBlock[];
internalLinks?: { text: string; href: string }[];
breadcrumbs?: { text: string; href: string }[];
}
// Page type-specific interfaces
export interface HomepageContent extends SEOPageContent {
pageType: 'homepage';
}
export interface StatePageContent extends SEOPageContent {
pageType: 'state';
stateCode: string;
stateName: string;
}
export interface AlternativePageContent extends SEOPageContent {
pageType: 'alternative';
competitorSlug: string;
competitorName: string;
}
export interface LandingPageContent extends SEOPageContent {
pageType: 'landing';
intent: 'high' | 'medium' | 'awareness';
}
export interface InsightPostContent extends SEOPageContent {
pageType: 'insight';
publishedAt: string;
updatedAt?: string;
author: string;
category: string;
tags: string[];
readTime: string;
}

View File

@@ -0,0 +1,78 @@
/**
* useStateFilter Hook
*
* Convenience wrapper around useStateStore that provides API-friendly helpers.
* Standardizes state filtering across all pages.
*
* Usage:
* const { selectedState, setSelectedState, stateParam, isAllStates, stateLabel } = useStateFilter();
*
* // Pass to API calls (undefined is omitted, specific state is included)
* api.getMetrics({ state: stateParam });
*
* // Display in UI
* <span>{stateLabel}</span>
*/
import { useStateStore } from '../store/stateStore';
export interface UseStateFilterReturn {
// The raw selected state (null = All States)
selectedState: string | null;
// Set the selected state
setSelectedState: (state: string | null) => void;
// API-friendly param: undefined for "All", state code for specific state
// Use in API calls: { state: stateParam } - undefined values get omitted
stateParam: string | undefined;
// Boolean check for "All States" mode
isAllStates: boolean;
// Human-readable label for current selection
stateLabel: string;
// Get full state name from code
getStateName: (code: string) => string;
// Available states for dropdowns
availableStates: Array<{ code: string; name: string }>;
// Loading state
isLoading: boolean;
}
export function useStateFilter(): UseStateFilterReturn {
const {
selectedState,
setSelectedState,
availableStates,
isLoading,
getStateName,
isNationalView,
} = useStateStore();
const isAllStates = isNationalView();
// API-friendly param: undefined for "All" (so it gets omitted in query params)
const stateParam = selectedState ?? undefined;
// Human-readable label
const stateLabel = isAllStates
? 'All States'
: getStateName(selectedState!) || selectedState!;
return {
selectedState,
setSelectedState,
stateParam,
isAllStates,
stateLabel,
getStateName,
availableStates,
isLoading,
};
}
export default useStateFilter;

View File

@@ -0,0 +1,163 @@
/**
* Analytics tracking utilities for CannaIQ
*
* Fire-and-forget tracking calls that don't block UI interactions.
*/
import { api } from './api';
export type ProductClickAction = 'view' | 'open_store' | 'open_product' | 'compare' | 'other';
export interface ProductClickEvent {
productId: string;
storeId?: string;
brandId?: string;
campaignId?: string;
action: ProductClickAction;
source: string;
pageType?: string; // Page where event occurred (e.g., StoreDetailPage, BrandsIntelligence)
}
/**
* Track a product click event (fire-and-forget)
*
* This function is non-blocking and will not throw errors.
* Events are sent to the backend asynchronously without waiting for response.
*
* @param event - The product click event data
*
* @example
* // Track a product view on the store detail page
* trackProductClick({
* productId: '12345',
* storeId: '101',
* action: 'view',
* source: 'store_detail'
* });
*
* @example
* // Track clicking through to a store from a product card
* trackProductClick({
* productId: '12345',
* storeId: '101',
* brandId: 'select',
* campaignId: '5',
* action: 'open_store',
* source: 'brand_page'
* });
*/
export function trackProductClick(event: ProductClickEvent): void {
// Fire-and-forget: don't await or handle errors
api.trackProductClick({
product_id: event.productId,
store_id: event.storeId,
brand_id: event.brandId,
campaign_id: event.campaignId,
action: event.action,
source: event.source,
page_type: event.pageType,
url_path: typeof window !== 'undefined' ? window.location.pathname : undefined,
}).catch(() => {
// Silently ignore errors - tracking should never break the UI
});
}
/**
* Track a product view event
*
* Convenience wrapper for tracking 'view' actions.
*
* @param productId - The product ID being viewed
* @param source - The page/component source (e.g., 'store_detail', 'brand_page')
* @param options - Optional store, brand, and campaign context
*/
export function trackProductView(
productId: string,
source: string,
options?: {
storeId?: string;
brandId?: string;
campaignId?: string;
}
): void {
trackProductClick({
productId,
action: 'view',
source,
...options,
});
}
/**
* Track opening a store from a product context
*
* @param productId - The product ID that led to the store click
* @param storeId - The store being opened
* @param source - The page/component source
* @param options - Optional brand and campaign context
*/
export function trackOpenStore(
productId: string,
storeId: string,
source: string,
options?: {
brandId?: string;
campaignId?: string;
}
): void {
trackProductClick({
productId,
storeId,
action: 'open_store',
source,
...options,
});
}
/**
* Track opening a product detail page
*
* @param productId - The product being opened
* @param source - The page/component source
* @param options - Optional store, brand, and campaign context
*/
export function trackOpenProduct(
productId: string,
source: string,
options?: {
storeId?: string;
brandId?: string;
campaignId?: string;
}
): void {
trackProductClick({
productId,
action: 'open_product',
source,
...options,
});
}
/**
* Track adding a product to comparison
*
* @param productId - The product being compared
* @param source - The page/component source
* @param options - Optional store, brand, and campaign context
*/
export function trackCompare(
productId: string,
source: string,
options?: {
storeId?: string;
brandId?: string;
campaignId?: string;
}
): void {
trackProductClick({
productId,
action: 'compare',
source,
...options,
});
}

View File

@@ -359,33 +359,33 @@ class ApiClient {
return this.request<any>(`/api/scraper-monitor/jobs/workers${params}`);
}
// AZ Monitor (Dutchie AZ live crawler status)
async getAZMonitorActiveJobs() {
// Market Monitor (live crawler status)
async getMonitorActiveJobs() {
return this.request<{
scheduledJobs: any[];
crawlJobs: any[];
inMemoryScrapers: any[];
totalActive: number;
}>('/api/az/monitor/active-jobs');
}>('/api/markets/monitor/active-jobs');
}
async getAZMonitorRecentJobs(limit?: number) {
async getMonitorRecentJobs(limit?: number) {
const params = limit ? `?limit=${limit}` : '';
return this.request<{
jobLogs: any[];
crawlJobs: any[];
}>(`/api/az/monitor/recent-jobs${params}`);
}>(`/api/markets/monitor/recent-jobs${params}`);
}
async getAZMonitorErrors(params?: { limit?: number; hours?: number }) {
async getMonitorErrors(params?: { limit?: number; hours?: number }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.hours) searchParams.append('hours', params.hours.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{ errors: any[] }>(`/api/az/monitor/errors${queryString}`);
return this.request<{ errors: any[] }>(`/api/markets/monitor/errors${queryString}`);
}
async getAZMonitorSummary() {
async getMonitorSummary() {
return this.request<{
running_scheduled_jobs: number;
running_crawl_jobs: number;
@@ -406,9 +406,15 @@ class ApiClient {
last_status: string;
last_run_at: string;
}>;
}>('/api/az/monitor/summary');
}>('/api/markets/monitor/summary');
}
// Legacy aliases (deprecated - use neutral names above)
getAZMonitorActiveJobs = this.getMonitorActiveJobs.bind(this);
getAZMonitorRecentJobs = this.getMonitorRecentJobs.bind(this);
getAZMonitorErrors = this.getMonitorErrors.bind(this);
getAZMonitorSummary = this.getMonitorSummary.bind(this);
// Change Approval
async getChanges(status?: 'pending' | 'approved' | 'rejected') {
const params = status ? `?status=${status}` : '';
@@ -653,11 +659,11 @@ class ApiClient {
}
// ============================================================
// AZ DATA API (formerly dutchie-az)
// MARKET DATA API (provider-agnostic)
// ============================================================
// Dutchie AZ Dashboard
async getDutchieAZDashboard() {
// Market Dashboard
async getMarketsDashboard() {
return this.request<{
dispensaryCount: number;
productCount: number;
@@ -666,11 +672,14 @@ class ApiClient {
failedJobCount: number;
brandCount: number;
categoryCount: number;
}>('/api/az/dashboard');
}>('/api/markets/dashboard');
}
// Dutchie AZ Schedules (CRUD)
async getDutchieAZSchedules() {
// Legacy alias
getDutchieAZDashboard = this.getMarketsDashboard.bind(this);
// Crawl Schedules (CRUD)
async getCrawlSchedules() {
return this.request<{
schedules: Array<{
id: number;
@@ -690,7 +699,7 @@ class ApiClient {
createdAt: string;
updatedAt: string;
}>;
}>('/api/az/admin/schedules');
}>('/api/markets/admin/schedules');
}
async getDutchieAZSchedule(id: number) {
@@ -709,7 +718,7 @@ class ApiClient {
jobConfig: Record<string, any> | null;
createdAt: string;
updatedAt: string;
}>(`/api/az/admin/schedules/${id}`);
}>(`/api/markets/admin/schedules/${id}`);
}
async createDutchieAZSchedule(data: {
@@ -721,7 +730,7 @@ class ApiClient {
jobConfig?: Record<string, any>;
startImmediately?: boolean;
}) {
return this.request<any>('/api/az/admin/schedules', {
return this.request<any>('/api/markets/admin/schedules', {
method: 'POST',
body: JSON.stringify(data),
});
@@ -734,37 +743,37 @@ class ApiClient {
jitterMinutes?: number;
jobConfig?: Record<string, any>;
}) {
return this.request<any>(`/api/az/admin/schedules/${id}`, {
return this.request<any>(`/api/markets/admin/schedules/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteDutchieAZSchedule(id: number) {
return this.request<{ success: boolean; message: string }>(`/api/az/admin/schedules/${id}`, {
return this.request<{ success: boolean; message: string }>(`/api/markets/admin/schedules/${id}`, {
method: 'DELETE',
});
}
async triggerDutchieAZSchedule(id: number) {
return this.request<{ success: boolean; message: string }>(`/api/az/admin/schedules/${id}/trigger`, {
return this.request<{ success: boolean; message: string }>(`/api/markets/admin/schedules/${id}/trigger`, {
method: 'POST',
});
}
async initDutchieAZSchedules() {
return this.request<{ success: boolean; schedules: any[] }>('/api/az/admin/schedules/init', {
return this.request<{ success: boolean; schedules: any[] }>('/api/markets/admin/schedules/init', {
method: 'POST',
});
}
// Dutchie AZ Run Logs
async getDutchieAZScheduleLogs(scheduleId: number, limit?: number, offset?: number) {
// Crawl Run Logs
async getCrawlScheduleLogs(scheduleId: number, limit?: number, offset?: number) {
const params = new URLSearchParams();
if (limit) params.append('limit', limit.toString());
if (offset) params.append('offset', offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return this.request<{ logs: any[]; total: number }>(`/api/az/admin/schedules/${scheduleId}/logs${queryString}`);
return this.request<{ logs: any[]; total: number }>(`/api/markets/admin/schedules/${scheduleId}/logs${queryString}`);
}
async getDutchieAZRunLogs(options?: { scheduleId?: number; jobName?: string; limit?: number; offset?: number }) {
@@ -774,45 +783,45 @@ class ApiClient {
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
const queryString = params.toString() ? `?${params.toString()}` : '';
return this.request<{ logs: any[]; total: number }>(`/api/az/admin/run-logs${queryString}`);
return this.request<{ logs: any[]; total: number }>(`/api/markets/admin/run-logs${queryString}`);
}
// Dutchie AZ Scheduler Control
async getDutchieAZSchedulerStatus() {
return this.request<{ running: boolean; pollIntervalMs: number }>('/api/az/admin/scheduler/status');
// Scheduler Control
async getCrawlSchedulerStatus() {
return this.request<{ running: boolean; pollIntervalMs: number }>('/api/markets/admin/scheduler/status');
}
async startDutchieAZScheduler() {
return this.request<{ success: boolean; message: string }>('/api/az/admin/scheduler/start', {
return this.request<{ success: boolean; message: string }>('/api/markets/admin/scheduler/start', {
method: 'POST',
});
}
async stopDutchieAZScheduler() {
return this.request<{ success: boolean; message: string }>('/api/az/admin/scheduler/stop', {
return this.request<{ success: boolean; message: string }>('/api/markets/admin/scheduler/stop', {
method: 'POST',
});
}
async triggerDutchieAZImmediateCrawl() {
return this.request<{ success: boolean; message: string }>('/api/az/admin/scheduler/trigger', {
return this.request<{ success: boolean; message: string }>('/api/markets/admin/scheduler/trigger', {
method: 'POST',
});
}
// Dutchie AZ Stores
async getDutchieAZStores(params?: { city?: string; hasPlatformId?: boolean; limit?: number; offset?: number }) {
// Market Stores
async getMarketStores(params?: { city?: string; hasPlatformId?: boolean; limit?: number; offset?: number }) {
const searchParams = new URLSearchParams();
if (params?.city) searchParams.append('city', params.city);
if (params?.hasPlatformId !== undefined) searchParams.append('hasPlatformId', String(params.hasPlatformId));
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{ stores: any[]; total: number }>(`/api/az/stores${queryString}`);
return this.request<{ stores: any[]; total: number }>(`/api/markets/stores${queryString}`);
}
async getDutchieAZStore(id: number) {
return this.request<any>(`/api/az/stores/${id}`);
return this.request<any>(`/api/markets/stores/${id}`);
}
async getDutchieAZStoreSummary(id: number) {
@@ -828,7 +837,7 @@ class ApiClient {
brandCount: number;
categoryCount: number;
lastCrawl: any | null;
}>(`/api/az/stores/${id}/summary`);
}>(`/api/markets/stores/${id}/summary`);
}
async getDutchieAZStoreProducts(id: number, params?: {
@@ -880,23 +889,23 @@ class ApiClient {
total: number;
limit: number;
offset: number;
}>(`/api/az/stores/${id}/products${queryString}`);
}>(`/api/markets/stores/${id}/products${queryString}`);
}
async getDutchieAZStoreBrands(id: number) {
return this.request<{
brands: Array<{ brand: string; product_count: number }>;
}>(`/api/az/stores/${id}/brands`);
}>(`/api/markets/stores/${id}/brands`);
}
async getDutchieAZStoreCategories(id: number) {
return this.request<{
categories: Array<{ type: string; subcategory: string; product_count: number }>;
}>(`/api/az/stores/${id}/categories`);
}>(`/api/markets/stores/${id}/categories`);
}
// Dutchie AZ Global Brands/Categories (from v_brands/v_categories views)
async getDutchieAZBrands(params?: { limit?: number; offset?: number }) {
// Global Brands/Categories (from v_brands/v_categories views)
async getMarketBrands(params?: { limit?: number; offset?: number }) {
const searchParams = new URLSearchParams();
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
@@ -910,7 +919,7 @@ class ApiClient {
dispensary_count: number;
product_types: string[];
}>;
}>(`/api/az/brands${queryString}`);
}>(`/api/markets/brands${queryString}`);
}
async getDutchieAZCategories() {
@@ -925,11 +934,11 @@ class ApiClient {
min_thc: number | null;
max_thc: number | null;
}>;
}>('/api/az/categories');
}>('/api/markets/categories');
}
// Dutchie AZ Debug
async getDutchieAZDebugSummary() {
// Debug Endpoints
async getMarketDebugSummary() {
return this.request<{
tableCounts: {
dispensary_count: string;
@@ -956,7 +965,7 @@ class ApiClient {
dispensary_name: string;
crawled_at: string;
}>;
}>('/api/az/debug/summary');
}>('/api/markets/debug/summary');
}
async getDutchieAZDebugStore(id: number) {
@@ -984,25 +993,25 @@ class ApiClient {
outOfStock: any[];
};
categories: Array<{ type: string; subcategory: string; count: string }>;
}>(`/api/az/debug/store/${id}`);
}>(`/api/markets/debug/store/${id}`);
}
async triggerDutchieAZCrawl(id: number, options?: { pricingType?: string; useBothModes?: boolean }) {
return this.request<any>(`/api/az/admin/crawl/${id}`, {
return this.request<any>(`/api/markets/admin/crawl/${id}`, {
method: 'POST',
body: JSON.stringify(options || {}),
});
}
// Dutchie AZ Menu Detection
async getDetectionStats() {
// Menu Detection
async getMenuDetectionStats() {
return this.request<{
totalDispensaries: number;
withMenuType: number;
withPlatformId: number;
needsDetection: number;
byProvider: Record<string, number>;
}>('/api/az/admin/detection/stats');
}>('/api/markets/admin/detection/stats');
}
async getDispensariesNeedingDetection(params?: { state?: string; limit?: number }) {
@@ -1010,7 +1019,7 @@ class ApiClient {
if (params?.state) searchParams.append('state', params.state);
if (params?.limit) searchParams.append('limit', params.limit.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{ dispensaries: any[]; total: number }>(`/api/az/admin/detection/pending${queryString}`);
return this.request<{ dispensaries: any[]; total: number }>(`/api/markets/admin/detection/pending${queryString}`);
}
async detectDispensary(id: number) {
@@ -1023,7 +1032,7 @@ class ApiClient {
platformDispensaryId: string | null;
success: boolean;
error?: string;
}>(`/api/az/admin/detection/detect/${id}`, {
}>(`/api/markets/admin/detection/detect/${id}`, {
method: 'POST',
});
}
@@ -1041,14 +1050,14 @@ class ApiClient {
totalSkipped: number;
results: any[];
errors: string[];
}>('/api/az/admin/detection/detect-all', {
}>('/api/markets/admin/detection/detect-all', {
method: 'POST',
body: JSON.stringify(options || {}),
});
}
async triggerMenuDetectionJob() {
return this.request<{ success: boolean; message: string }>('/api/az/admin/detection/trigger', {
return this.request<{ success: boolean; message: string }>('/api/markets/admin/detection/trigger', {
method: 'POST',
});
}
@@ -1116,7 +1125,7 @@ class ApiClient {
status: string;
error?: string;
}>;
}>(`/api/az/admin/dispensaries/${dispensaryId}/crawl-trace/latest`);
}>(`/api/markets/admin/dispensaries/${dispensaryId}/crawl-trace/latest`);
}
async getDispensaryTraces(dispensaryId: number, params?: { limit?: number; offset?: number }) {
@@ -1141,7 +1150,7 @@ class ApiClient {
completedAt: string | null;
}>;
total: number;
}>(`/api/az/admin/dispensaries/${dispensaryId}/crawl-traces${queryString}`);
}>(`/api/markets/admin/dispensaries/${dispensaryId}/crawl-traces${queryString}`);
}
async getTraceById(traceId: number) {
@@ -1177,7 +1186,7 @@ class ApiClient {
status: string;
error?: string;
}>;
}>(`/api/az/admin/crawl-traces/${traceId}`);
}>(`/api/markets/admin/crawl-traces/${traceId}`);
}
// ============================================================
@@ -2025,6 +2034,394 @@ class ApiClient {
}>(`/api/admin/debug/snapshots/${snapshotId}/raw-payload`);
}
// ============================================================
// HEALTH CHECK API
// ============================================================
async getHealth() {
return this.request<{
status: 'ok' | 'degraded' | 'error';
uptime: number;
timestamp: string;
version: string;
}>('/api/health');
}
async getHealthDb() {
return this.request<{
status: 'ok' | 'degraded' | 'error';
connected: boolean;
latency_ms: number;
error?: string;
}>('/api/health/db');
}
async getHealthRedis() {
return this.request<{
status: 'ok' | 'degraded' | 'error';
connected: boolean;
latency_ms: number;
error?: string;
}>('/api/health/redis');
}
async getHealthWorkers() {
return this.request<{
status: 'ok' | 'degraded' | 'error';
queues: Array<{
name: string;
waiting: number;
active: number;
completed: number;
failed: number;
paused: boolean;
}>;
workers: Array<{
id: string;
queue: string;
status: string;
last_heartbeat?: string;
}>;
}>('/api/health/workers');
}
async getHealthCrawls() {
return this.request<{
status: 'ok' | 'degraded' | 'stale' | 'error';
last_run: string | null;
runs_last_24h: number;
stores_with_recent_crawl: number;
stores_total: number;
stale_stores: number;
}>('/api/health/crawls');
}
async getHealthAnalytics() {
return this.request<{
status: 'ok' | 'degraded' | 'stale' | 'error';
last_aggregate: string | null;
daily_runs_last_7d: number;
missing_days: number;
}>('/api/health/analytics');
}
async getHealthFull() {
return this.request<{
status: 'ok' | 'degraded' | 'error';
api: {
status: 'ok' | 'degraded' | 'error';
uptime: number;
timestamp: string;
version: string;
};
db: {
status: 'ok' | 'degraded' | 'error';
connected: boolean;
latency_ms: number;
error?: string;
};
redis: {
status: 'ok' | 'degraded' | 'error';
connected: boolean;
latency_ms: number;
error?: string;
};
workers: {
status: 'ok' | 'degraded' | 'error';
queues: Array<{
name: string;
waiting: number;
active: number;
completed: number;
failed: number;
paused: boolean;
}>;
workers: Array<{
id: string;
queue: string;
status: string;
last_heartbeat?: string;
}>;
};
crawls: {
status: 'ok' | 'degraded' | 'stale' | 'error';
last_run: string | null;
runs_last_24h: number;
stores_with_recent_crawl: number;
stores_total: number;
stale_stores: number;
};
analytics: {
status: 'ok' | 'degraded' | 'stale' | 'error';
last_aggregate: string | null;
daily_runs_last_7d: number;
missing_days: number;
};
}>('/api/health/full');
}
// ============================================================
// EVENTS / ANALYTICS TRACKING
// ============================================================
/**
* Track a product click event (fire-and-forget)
* @param event - The product click event data
* @returns Promise that resolves to {status: 'ok'} or {status: 'error'}
*/
async trackProductClick(event: {
product_id: string;
store_id?: string;
brand_id?: string;
campaign_id?: string;
action: 'view' | 'open_store' | 'open_product' | 'compare' | 'other';
source: string;
page_type?: string;
url_path?: string;
}) {
try {
return await this.request<{ status: string }>('/api/events/product-click', {
method: 'POST',
body: JSON.stringify(event),
});
} catch {
// Fire-and-forget: don't throw errors for tracking failures
return { status: 'error' };
}
}
/**
* Get product click events (admin only)
*/
async getProductClickEvents(params?: {
product_id?: string;
store_id?: string;
brand_id?: string;
campaign_id?: string;
action?: string;
source?: string;
from?: string;
to?: string;
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.product_id) searchParams.append('product_id', params.product_id);
if (params?.store_id) searchParams.append('store_id', params.store_id);
if (params?.brand_id) searchParams.append('brand_id', params.brand_id);
if (params?.campaign_id) searchParams.append('campaign_id', params.campaign_id);
if (params?.action) searchParams.append('action', params.action);
if (params?.source) searchParams.append('source', params.source);
if (params?.from) searchParams.append('from', params.from);
if (params?.to) searchParams.append('to', params.to);
if (params?.limit) searchParams.append('limit', params.limit.toString());
if (params?.offset) searchParams.append('offset', params.offset.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
events: Array<{
id: number;
product_id: string;
store_id: string | null;
brand_id: string | null;
campaign_id: number | null;
action: string;
source: string;
user_id: number | null;
ip_address: string | null;
user_agent: string | null;
occurred_at: string;
created_at: string;
}>;
total: number;
limit: number;
offset: number;
}>(`/api/events/product-clicks${queryString}`);
}
// ============================================================
// CLICK ANALYTICS API - Brand & Campaign Engagement
// ============================================================
/**
* Get top brands by click engagement
*/
async getClickAnalyticsBrands(params?: {
state?: string;
store_id?: string;
brand_id?: string;
days?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.store_id) searchParams.append('store_id', params.store_id);
if (params?.brand_id) searchParams.append('brand_id', params.brand_id);
if (params?.days) searchParams.append('days', params.days.toString());
if (params?.limit) searchParams.append('limit', params.limit.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
filters: {
state: string | null;
store_id: string | null;
brand_id: string | null;
days: number;
};
brands: Array<{
brand_id: string;
brand_name: string;
clicks: number;
unique_products: number;
unique_stores: number;
first_click_at: string;
last_click_at: string;
}>;
}>(`/api/analytics/clicks/brands${queryString}`);
}
/**
* Get top campaigns by click engagement
*/
async getClickAnalyticsCampaigns(params?: {
state?: string;
store_id?: string;
days?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.store_id) searchParams.append('store_id', params.store_id);
if (params?.days) searchParams.append('days', params.days.toString());
if (params?.limit) searchParams.append('limit', params.limit.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
filters: {
state: string | null;
store_id: string | null;
days: number;
};
campaigns: Array<{
campaign_id: number;
campaign_name: string;
campaign_slug: string | null;
campaign_description: string | null;
is_active: boolean;
start_date: string | null;
end_date: string | null;
clicks: number;
unique_products: number;
unique_stores: number;
first_event_at: string;
last_event_at: string;
}>;
}>(`/api/analytics/clicks/campaigns${queryString}`);
}
/**
* Get top products by click engagement
*/
async getClickAnalyticsProducts(params?: {
state?: string;
store_id?: string;
brand_id?: string;
days?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.store_id) searchParams.append('store_id', params.store_id);
if (params?.brand_id) searchParams.append('brand_id', params.brand_id);
if (params?.days) searchParams.append('days', params.days.toString());
if (params?.limit) searchParams.append('limit', params.limit.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
filters: {
state: string | null;
store_id: string | null;
brand_id: string | null;
days: number;
};
products: Array<{
product_id: string;
product_name: string;
brand_id: string | null;
brand_name: string;
category: string | null;
subcategory: string | null;
clicks: number;
unique_stores: number;
first_click_at: string;
last_click_at: string;
}>;
}>(`/api/analytics/clicks/products${queryString}`);
}
/**
* Get brand engagement for a specific store
*/
async getClickAnalyticsStoreBrands(storeId: number | string, params?: {
days?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.days) searchParams.append('days', params.days.toString());
if (params?.limit) searchParams.append('limit', params.limit.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
store: {
id: number;
name: string;
city: string;
state: string;
};
filters: {
days: number;
};
brands: Array<{
brand_id: string;
brand_name: string;
clicks: number;
unique_products: number;
first_click_at: string;
last_click_at: string;
}>;
}>(`/api/analytics/clicks/stores/${storeId}/brands${queryString}`);
}
/**
* Get overall click summary stats
*/
async getClickAnalyticsSummary(params?: {
state?: string;
days?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.days) searchParams.append('days', params.days.toString());
const queryString = searchParams.toString() ? `?${searchParams.toString()}` : '';
return this.request<{
filters: {
state: string | null;
days: number;
};
summary: {
total_clicks: number;
unique_products: number;
unique_stores: number;
unique_brands: number;
campaign_clicks: number;
unique_campaigns: number;
};
by_action: Array<{
action: string;
count: number;
}>;
daily: Array<{
date: string;
clicks: number;
}>;
}>(`/api/analytics/clicks/summary${queryString}`);
}
// Scraper Overview Dashboard
async getScraperOverview() {
return this.request<{
@@ -2081,7 +2478,92 @@ class ApiClient {
latestLoss: string | null;
latestRestore: string | null;
}>;
}>('/api/dutchie-az/scraper/overview');
}>('/api/markets/scraper/overview');
}
// ============================================================
// MARKET DATA ALIASES (for new components)
// Note: These use "Market" prefix to avoid conflicts with legacy methods
// ============================================================
// Market store methods (market data pipeline)
getMarketStoreSummary = this.getDutchieAZStoreSummary.bind(this);
getMarketStoreProducts = this.getDutchieAZStoreProducts.bind(this);
getMarketStoreBrands = this.getDutchieAZStoreBrands.bind(this);
getMarketStoreCategories = this.getDutchieAZStoreCategories.bind(this);
triggerMarketStoreCrawl = this.triggerDutchieAZCrawl.bind(this);
// Dashboard methods
getMarketDashboard = this.getMarketsDashboard.bind(this);
// Schedule methods (no conflicts)
getSchedules = this.getCrawlSchedules.bind(this);
getSchedule = this.getDutchieAZSchedule.bind(this);
createSchedule = this.createDutchieAZSchedule.bind(this);
updateSchedule = this.updateDutchieAZSchedule.bind(this);
deleteSchedule = this.deleteDutchieAZSchedule.bind(this);
triggerSchedule = this.triggerDutchieAZSchedule.bind(this);
initSchedules = this.initDutchieAZSchedules.bind(this);
getScheduleLogs = this.getCrawlScheduleLogs.bind(this);
getRunLogs = this.getDutchieAZRunLogs.bind(this);
getSchedulerStatus = this.getCrawlSchedulerStatus.bind(this);
startScheduler = this.startDutchieAZScheduler.bind(this);
stopScheduler = this.stopDutchieAZScheduler.bind(this);
triggerImmediateCrawl = this.triggerDutchieAZImmediateCrawl.bind(this);
// Market brands/categories
getMarketCategories = this.getDutchieAZCategories.bind(this);
// Detection
getDetectionStats = this.getMenuDetectionStats.bind(this);
// ============================================================
// LEGACY ALIASES (deprecated - for backward compatibility)
// ============================================================
getDutchieAZStores = this.getMarketStores.bind(this);
getDutchieAZSchedules = this.getCrawlSchedules.bind(this);
getDutchieAZScheduleLogs = this.getCrawlScheduleLogs.bind(this);
getDutchieAZSchedulerStatus = this.getCrawlSchedulerStatus.bind(this);
getDutchieAZBrands = this.getMarketBrands.bind(this);
getDutchieAZDebugSummary = this.getMarketDebugSummary.bind(this);
// ============================================================
// SEO ORCHESTRATOR
// ============================================================
async getSeoPages(params?: { type?: string; status?: string; search?: string }) {
const queryParams = new URLSearchParams();
if (params?.type) queryParams.append('type', params.type);
if (params?.status) queryParams.append('status', params.status);
if (params?.search) queryParams.append('search', params.search);
const query = queryParams.toString();
return this.request<{ pages: any[] }>(`/api/seo/pages${query ? `?${query}` : ''}`);
}
async getStateMetrics() {
return this.request<{ states: any[] }>('/api/seo/state-metrics');
}
async syncStatePages() {
return this.request<{ message: string }>('/api/seo/sync-state-pages', { method: 'POST' });
}
async updateSeoPage(id: number, data: { status?: string }) {
return this.request<{ success: boolean }>(`/api/seo/pages/${id}`, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
async generateSeoPage(pageId: number) {
return this.request<{ success: boolean; content: any }>(`/api/seo/pages/${pageId}/generate`, {
method: 'POST'
});
}
async getSeoPublicContent(slug: string) {
return this.request<{ slug: string; type: string; meta: any; blocks: any[] }>(
`/api/seo/public/content?slug=${encodeURIComponent(slug)}`
);
}
}

View File

@@ -0,0 +1,647 @@
import { useEffect, useState } from 'react';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { useStateFilter } from '../hooks/useStateFilter';
import {
Tag,
MousePointerClick,
TrendingUp,
Building2,
Package,
RefreshCw,
Megaphone,
BarChart3,
Store,
} from 'lucide-react';
interface BrandEngagement {
brand_id: string;
brand_name: string;
clicks: number;
unique_products: number;
unique_stores: number;
first_click_at: string;
last_click_at: string;
}
interface ProductEngagement {
product_id: string;
product_name: string;
brand_id: string | null;
brand_name: string;
category: string | null;
subcategory: string | null;
clicks: number;
unique_stores: number;
first_click_at: string;
last_click_at: string;
}
interface CampaignEngagement {
campaign_id: number;
campaign_name: string;
campaign_slug: string | null;
campaign_description: string | null;
is_active: boolean;
start_date: string | null;
end_date: string | null;
clicks: number;
unique_products: number;
unique_stores: number;
first_event_at: string;
last_event_at: string;
}
interface StoreOption {
id: number;
name: string;
}
interface ClickSummary {
total_clicks: number;
unique_products: number;
unique_stores: number;
unique_brands: number;
campaign_clicks: number;
unique_campaigns: number;
}
interface ActionBreakdown {
action: string;
count: number;
}
interface DailyClicks {
date: string;
clicks: number;
}
export function ClickAnalytics() {
const { selectedState } = useStateFilter();
const [loading, setLoading] = useState(true);
const [days, setDays] = useState(30);
const [storeId, setStoreId] = useState<string>('');
const [stores, setStores] = useState<StoreOption[]>([]);
const [summary, setSummary] = useState<ClickSummary | null>(null);
const [byAction, setByAction] = useState<ActionBreakdown[]>([]);
const [daily, setDaily] = useState<DailyClicks[]>([]);
const [brands, setBrands] = useState<BrandEngagement[]>([]);
const [products, setProducts] = useState<ProductEngagement[]>([]);
const [campaigns, setCampaigns] = useState<CampaignEngagement[]>([]);
const [activeTab, setActiveTab] = useState<'overview' | 'brands' | 'products' | 'campaigns'>('overview');
// Fetch stores for filter dropdown
useEffect(() => {
const fetchStores = async () => {
try {
const result = await api.getMarketStores({ limit: 200 });
// Filter by state if selected
const filteredStores = selectedState && selectedState !== 'ALL'
? result.stores.filter((s: any) => s.state === selectedState)
: result.stores;
setStores(filteredStores.map((s: any) => ({ id: s.id, name: s.dba_name || s.name })));
} catch (err) {
console.error('Failed to fetch stores:', err);
}
};
fetchStores();
}, [selectedState]);
useEffect(() => {
loadData();
}, [days, selectedState, storeId]);
const loadData = async () => {
setLoading(true);
try {
const state = selectedState && selectedState !== 'ALL' ? selectedState : undefined;
const params = { days, state, store_id: storeId || undefined };
const [summaryData, brandsData, productsData, campaignsData] = await Promise.all([
api.getClickAnalyticsSummary({ days, state }),
api.getClickAnalyticsBrands({ ...params, limit: 50 }),
api.getClickAnalyticsProducts({ ...params, limit: 50 }),
api.getClickAnalyticsCampaigns({ ...params, limit: 25 }),
]);
setSummary(summaryData.summary);
setByAction(summaryData.by_action || []);
setDaily(summaryData.daily || []);
setBrands(brandsData.brands || []);
setProducts(productsData.products || []);
setCampaigns(campaignsData.campaigns || []);
} catch (error) {
console.error('Failed to load click analytics:', error);
} finally {
setLoading(false);
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
};
const formatRelativeTime = (dateStr: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffHours < 1) return 'Just now';
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
};
if (loading) {
return (
<Layout>
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-emerald-500 border-t-transparent"></div>
<p className="mt-2 text-sm text-gray-600">Loading click analytics...</p>
</div>
</Layout>
);
}
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<MousePointerClick className="w-7 h-7 text-emerald-600" />
Click Analytics
</h1>
<p className="text-sm text-gray-600 mt-1">
Track engagement across brands, products, and campaigns
{selectedState && selectedState !== 'ALL' && (
<span className="ml-2 px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded text-xs font-medium">
{selectedState}
</span>
)}
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Store className="w-4 h-4 text-gray-400" />
<select
value={storeId}
onChange={(e) => setStoreId(e.target.value)}
className="select select-bordered select-sm min-w-[180px]"
>
<option value="">All Stores</option>
{stores.map(store => (
<option key={store.id} value={store.id}>{store.name}</option>
))}
</select>
</div>
<select
value={days}
onChange={(e) => setDays(parseInt(e.target.value))}
className="select select-bordered select-sm"
>
<option value={7}>Last 7 days</option>
<option value={14}>Last 14 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
</select>
<button
onClick={loadData}
disabled={loading}
className="btn btn-sm btn-outline"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard
title="Total Clicks"
value={summary?.total_clicks || 0}
icon={<MousePointerClick className="w-5 h-5 text-emerald-600" />}
color="emerald"
/>
<StatCard
title="Unique Products"
value={summary?.unique_products || 0}
icon={<Package className="w-5 h-5 text-blue-600" />}
color="blue"
/>
<StatCard
title="Unique Stores"
value={summary?.unique_stores || 0}
icon={<Building2 className="w-5 h-5 text-purple-600" />}
color="purple"
/>
<StatCard
title="Unique Brands"
value={summary?.unique_brands || 0}
icon={<Tag className="w-5 h-5 text-orange-600" />}
color="orange"
/>
<StatCard
title="Campaign Clicks"
value={summary?.campaign_clicks || 0}
icon={<Megaphone className="w-5 h-5 text-pink-600" />}
color="pink"
/>
<StatCard
title="Active Campaigns"
value={summary?.unique_campaigns || 0}
icon={<TrendingUp className="w-5 h-5 text-cyan-600" />}
color="cyan"
/>
</div>
{/* Tab Navigation */}
<div className="bg-white rounded-lg border border-gray-200">
<div className="border-b border-gray-200">
<div className="flex gap-4 px-6">
<TabButton
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
icon={<BarChart3 className="w-4 h-4" />}
label="Overview"
/>
<TabButton
active={activeTab === 'brands'}
onClick={() => setActiveTab('brands')}
icon={<Tag className="w-4 h-4" />}
label={`Brands (${brands.length})`}
/>
<TabButton
active={activeTab === 'products'}
onClick={() => setActiveTab('products')}
icon={<Package className="w-4 h-4" />}
label={`Products (${products.length})`}
/>
<TabButton
active={activeTab === 'campaigns'}
onClick={() => setActiveTab('campaigns')}
icon={<Megaphone className="w-4 h-4" />}
label={`Campaigns (${campaigns.length})`}
/>
</div>
</div>
<div className="p-6">
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="space-y-6">
{/* Daily Click Chart */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Clicks Over Time</h3>
{daily.length > 0 ? (
<div className="h-48 flex items-end gap-1">
{daily.map((day, idx) => {
const maxClicks = Math.max(...daily.map((d) => d.clicks), 1);
const height = (day.clicks / maxClicks) * 100;
return (
<div
key={idx}
className="flex-1 flex flex-col items-center group"
>
<div
className="w-full bg-emerald-500 rounded-t transition-all hover:bg-emerald-600"
style={{ height: `${Math.max(height, 2)}%` }}
title={`${formatDate(day.date)}: ${day.clicks} clicks`}
/>
{idx % Math.ceil(daily.length / 7) === 0 && (
<span className="text-xs text-gray-500 mt-1 transform -rotate-45 origin-left">
{formatDate(day.date)}
</span>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-500">
No click data for this period
</div>
)}
</div>
{/* Actions Breakdown */}
{byAction.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">Clicks by Action</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{byAction.map((action) => (
<div
key={action.action}
className="bg-gray-50 rounded-lg p-4 text-center"
>
<p className="text-2xl font-bold text-gray-900">
{action.count.toLocaleString()}
</p>
<p className="text-sm text-gray-600 capitalize">
{action.action.replace('_', ' ')}
</p>
</div>
))}
</div>
</div>
)}
{/* Top Brands Preview */}
{brands.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Top Brands</h3>
<button
onClick={() => setActiveTab('brands')}
className="text-sm text-emerald-600 hover:underline"
>
View All
</button>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{brands.slice(0, 6).map((brand) => (
<div
key={brand.brand_id}
className="bg-gray-50 rounded-lg p-4 text-center"
>
<p className="font-medium text-gray-900 text-sm truncate">
{brand.brand_name}
</p>
<p className="text-xl font-bold text-emerald-600 mt-1">
{brand.clicks}
</p>
<p className="text-xs text-gray-500">clicks</p>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Brands Tab */}
{activeTab === 'brands' && (
<div className="space-y-4">
{brands.length === 0 ? (
<p className="text-center py-8 text-gray-500">
No brand engagement data for this period
</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>Brand</th>
<th className="text-right">Clicks</th>
<th className="text-right">Products</th>
<th className="text-right">Stores</th>
<th>First Click</th>
<th>Last Click</th>
</tr>
</thead>
<tbody>
{brands.map((brand, idx) => (
<tr key={brand.brand_id} className="hover">
<td>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">
#{idx + 1}
</span>
<span className="font-medium">{brand.brand_name}</span>
</div>
</td>
<td className="text-right">
<span className="badge badge-success badge-sm">
{brand.clicks}
</span>
</td>
<td className="text-right text-gray-600">
{brand.unique_products}
</td>
<td className="text-right text-gray-600">
{brand.unique_stores}
</td>
<td className="text-sm text-gray-500">
{formatRelativeTime(brand.first_click_at)}
</td>
<td className="text-sm text-gray-500">
{formatRelativeTime(brand.last_click_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Products Tab */}
{activeTab === 'products' && (
<div className="space-y-4">
{products.length === 0 ? (
<p className="text-center py-8 text-gray-500">
No product engagement data for this period
</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>Product</th>
<th>Brand</th>
<th>Category</th>
<th className="text-right">Clicks</th>
<th className="text-right">Stores</th>
<th>Last Click</th>
</tr>
</thead>
<tbody>
{products.map((product, idx) => (
<tr key={product.product_id} className="hover">
<td>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">
#{idx + 1}
</span>
<span className="font-medium truncate max-w-xs" title={product.product_name}>
{product.product_name}
</span>
</div>
</td>
<td className="text-gray-600">
{product.brand_name}
</td>
<td className="text-sm text-gray-500">
{product.category || '-'}
{product.subcategory && ` / ${product.subcategory}`}
</td>
<td className="text-right">
<span className="badge badge-info badge-sm">
{product.clicks}
</span>
</td>
<td className="text-right text-gray-600">
{product.unique_stores}
</td>
<td className="text-sm text-gray-500">
{formatRelativeTime(product.last_click_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Campaigns Tab */}
{activeTab === 'campaigns' && (
<div className="space-y-4">
{campaigns.length === 0 ? (
<p className="text-center py-8 text-gray-500">
No campaign engagement data for this period
</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm w-full">
<thead>
<tr>
<th>Campaign</th>
<th className="text-center">Status</th>
<th className="text-right">Clicks</th>
<th className="text-right">Products</th>
<th className="text-right">Stores</th>
<th>Period</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody>
{campaigns.map((campaign, idx) => (
<tr key={campaign.campaign_id} className="hover">
<td>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-sm">
#{idx + 1}
</span>
<div>
<p className="font-medium">{campaign.campaign_name}</p>
{campaign.campaign_description && (
<p className="text-xs text-gray-500 truncate max-w-xs">
{campaign.campaign_description}
</p>
)}
</div>
</div>
</td>
<td className="text-center">
{campaign.is_active ? (
<span className="badge badge-success badge-sm">Active</span>
) : (
<span className="badge badge-ghost badge-sm">Ended</span>
)}
</td>
<td className="text-right">
<span className="badge badge-primary badge-sm">
{campaign.clicks}
</span>
</td>
<td className="text-right text-gray-600">
{campaign.unique_products}
</td>
<td className="text-right text-gray-600">
{campaign.unique_stores}
</td>
<td className="text-sm text-gray-500">
{campaign.start_date && campaign.end_date ? (
<>
{formatDate(campaign.start_date)} -{' '}
{formatDate(campaign.end_date)}
</>
) : campaign.start_date ? (
<>From {formatDate(campaign.start_date)}</>
) : (
'-'
)}
</td>
<td className="text-sm text-gray-500">
{formatRelativeTime(campaign.last_event_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
</div>
</div>
</Layout>
);
}
interface StatCardProps {
title: string;
value: number;
icon: React.ReactNode;
color: string;
}
function StatCard({ title, value, icon, color }: StatCardProps) {
const colorClasses: Record<string, string> = {
emerald: 'bg-emerald-50',
blue: 'bg-blue-50',
purple: 'bg-purple-50',
orange: 'bg-orange-50',
pink: 'bg-pink-50',
cyan: 'bg-cyan-50',
};
return (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<div className={`p-2 ${colorClasses[color] || 'bg-gray-50'} rounded-lg`}>
{icon}
</div>
<div>
<p className="text-xs text-gray-600">{title}</p>
<p className="text-xl font-bold text-gray-900">{value.toLocaleString()}</p>
</div>
</div>
</div>
);
}
interface TabButtonProps {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
}
function TabButton({ active, onClick, icon, label }: TabButtonProps) {
return (
<button
onClick={onClick}
className={`flex items-center gap-2 py-4 px-2 text-sm font-medium border-b-2 transition-colors ${
active
? 'border-emerald-600 text-emerald-600'
: 'border-transparent text-gray-600 hover:text-gray-900'
}`}
>
{icon}
{label}
</button>
);
}

View File

@@ -51,7 +51,7 @@ interface DetectionStats {
byProvider: Record<string, number>;
}
export function DutchieAZSchedule() {
export function CrawlSchedulePage() {
const [schedules, setSchedules] = useState<JobSchedule[]>([]);
const [runLogs, setRunLogs] = useState<RunLog[]>([]);
const [schedulerStatus, setSchedulerStatus] = useState<{ running: boolean; pollIntervalMs: number } | null>(null);
@@ -76,9 +76,9 @@ export function DutchieAZSchedule() {
const loadData = async () => {
try {
const [schedulesData, logsData, statusData, detectionData] = await Promise.all([
api.getDutchieAZSchedules(),
api.getDutchieAZRunLogs({ limit: 50 }),
api.getDutchieAZSchedulerStatus(),
api.getSchedules(),
api.getRunLogs({ limit: 50 }),
api.getSchedulerStatus(),
api.getDetectionStats().catch(() => null),
]);
@@ -96,9 +96,9 @@ export function DutchieAZSchedule() {
const handleToggleScheduler = async () => {
try {
if (schedulerStatus?.running) {
await api.stopDutchieAZScheduler();
await api.stopScheduler();
} else {
await api.startDutchieAZScheduler();
await api.startScheduler();
}
await loadData();
} catch (error) {
@@ -108,7 +108,7 @@ export function DutchieAZSchedule() {
const handleInitSchedules = async () => {
try {
await api.initDutchieAZSchedules();
await api.initSchedules();
await loadData();
} catch (error) {
console.error('Failed to initialize schedules:', error);
@@ -117,7 +117,7 @@ export function DutchieAZSchedule() {
const handleTriggerSchedule = async (id: number) => {
try {
await api.triggerDutchieAZSchedule(id);
await api.triggerSchedule(id);
await loadData();
} catch (error) {
console.error('Failed to trigger schedule:', error);
@@ -126,7 +126,7 @@ export function DutchieAZSchedule() {
const handleToggleEnabled = async (schedule: JobSchedule) => {
try {
await api.updateDutchieAZSchedule(schedule.id, { enabled: !schedule.enabled });
await api.updateSchedule(schedule.id, { enabled: !schedule.enabled });
await loadData();
} catch (error) {
console.error('Failed to toggle schedule:', error);
@@ -142,7 +142,7 @@ export function DutchieAZSchedule() {
jitterMinutes: updates.jitterMinutes,
jobConfig: updates.jobConfig ?? undefined,
};
await api.updateDutchieAZSchedule(id, payload);
await api.updateSchedule(id, payload);
setEditingSchedule(null);
await loadData();
} catch (error) {
@@ -153,7 +153,7 @@ export function DutchieAZSchedule() {
const handleDeleteSchedule = async (id: number) => {
if (!confirm('Are you sure you want to delete this schedule?')) return;
try {
await api.deleteDutchieAZSchedule(id);
await api.deleteSchedule(id);
await loadData();
} catch (error) {
console.error('Failed to delete schedule:', error);
@@ -176,7 +176,7 @@ export function DutchieAZSchedule() {
};
const handleDetectMissingIds = async () => {
if (!confirm('Resolve platform IDs for all Dutchie dispensaries missing them?')) return;
if (!confirm('Resolve platform IDs for all dispensaries missing them?')) return;
setDetectingAll(true);
setDetectionResults(null);
try {
@@ -259,9 +259,9 @@ export function DutchieAZSchedule() {
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '30px' }}>
<div>
<h1 style={{ fontSize: '32px', margin: 0 }}>Dutchie AZ Schedule</h1>
<h1 style={{ fontSize: '32px', margin: 0 }}>Crawl Schedule</h1>
<p style={{ color: '#666', margin: '8px 0 0 0' }}>
Jittered scheduling for Arizona Dutchie product crawls
Jittered scheduling for product crawls
</p>
</div>
<div style={{ display: 'flex', gap: '15px', alignItems: 'center' }}>

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Layout } from '../components/Layout';
import { HealthPanel } from '../components/HealthPanel';
import { api } from '../lib/api';
import {
Store,
@@ -61,32 +62,32 @@ export function Dashboard() {
const loadData = async () => {
try {
// Fetch AZ dashboard data (primary data source)
const azDashboard = await api.getDutchieAZDashboard();
// Fetch dashboard data (primary data source)
const dashboard = await api.getMarketDashboard();
// Map AZ dashboard data to the expected stats format
// Map dashboard data to the expected stats format
setStats({
products: {
total: azDashboard.productCount,
in_stock: azDashboard.productCount, // All AZ products are in stock by default
with_images: 0 // Not tracked in AZ pipeline
total: dashboard.productCount,
in_stock: dashboard.productCount, // All products are in stock by default
with_images: 0 // Not tracked in pipeline
},
stores: {
total: azDashboard.dispensaryCount,
active: azDashboard.dispensaryCount // All are active in AZ
total: dashboard.dispensaryCount,
active: dashboard.dispensaryCount // All are active
},
brands: {
total: azDashboard.brandCount
total: dashboard.brandCount
},
campaigns: {
active: 0,
total: 0
},
clicks: {
clicks_24h: azDashboard.snapshotCount24h // Use snapshots as activity metric
clicks_24h: dashboard.snapshotCount24h // Use snapshots as activity metric
},
failedJobs: azDashboard.failedJobCount,
lastCrawlTime: azDashboard.lastCrawlTime
failedJobs: dashboard.failedJobCount,
lastCrawlTime: dashboard.lastCrawlTime
});
// Try to fetch activity data (may fail if not authenticated)
@@ -148,22 +149,22 @@ export function Dashboard() {
<Layout>
{/* Pending Changes Notification */}
{showNotification && (
<div className="mb-6 bg-amber-50 border-l-4 border-amber-500 rounded-lg p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0" />
<div className="mb-6 bg-amber-50 border-l-4 border-amber-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">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5 sm:mt-0" />
<div className="flex-1">
<h3 className="text-sm font-semibold text-amber-900">
{pendingChangesCount} pending change{pendingChangesCount !== 1 ? 's' : ''} require review
</h3>
<p className="text-sm text-amber-700 mt-0.5">
<p className="text-xs sm:text-sm text-amber-700 mt-0.5">
Proposed changes to dispensary data are waiting for approval
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 pl-8 sm:pl-0">
<button className="btn btn-sm bg-amber-600 hover:bg-amber-700 text-white border-none">
Review Changes
Review
</button>
<button
onClick={handleDismissNotification}
@@ -179,24 +180,27 @@ export function Dashboard() {
<div className="space-y-8">
{/* Header */}
<div className="flex justify-between items-center">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Dashboard</h1>
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900">Dashboard</h1>
<p className="text-sm text-gray-500 mt-1">Monitor your dispensary data aggregation</p>
</div>
<button
onClick={loadData}
className="inline-flex items-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700"
className="inline-flex items-center justify-center gap-2 px-4 py-2 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors text-sm font-medium text-gray-700 self-start sm:self-auto"
>
<RefreshCw className="w-4 h-4" />
Refresh
</button>
</div>
{/* System Health */}
<HealthPanel showQueues={false} refreshInterval={60000} />
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-6">
{/* Products */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-blue-50 rounded-lg">
<Package className="w-5 h-5 text-blue-600" />
@@ -207,14 +211,14 @@ export function Dashboard() {
</div>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-600">Total Products</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.products?.total?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500">{stats?.products?.in_stock || 0} in stock</p>
<p className="text-xs sm:text-sm font-medium text-gray-600">Total Products</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.products?.total?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">{stats?.products?.in_stock || 0} in stock</p>
</div>
</div>
{/* Stores */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-emerald-50 rounded-lg">
<Store className="w-5 h-5 text-emerald-600" />
@@ -225,14 +229,14 @@ export function Dashboard() {
</div>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-600">Total Dispensaries</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.stores?.total || 0}</p>
<p className="text-xs text-gray-500">{stats?.stores?.active || 0} active (crawlable)</p>
<p className="text-xs sm:text-sm font-medium text-gray-600">Dispensaries</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.stores?.total || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">{stats?.stores?.active || 0} active</p>
</div>
</div>
{/* Campaigns */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-purple-50 rounded-lg">
<Target className="w-5 h-5 text-purple-600" />
@@ -243,14 +247,14 @@ export function Dashboard() {
</div>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-600">Active Campaigns</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.campaigns?.active || 0}</p>
<p className="text-xs text-gray-500">{stats?.campaigns?.total || 0} total campaigns</p>
<p className="text-xs sm:text-sm font-medium text-gray-600">Campaigns</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.campaigns?.active || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">{stats?.campaigns?.total || 0} total</p>
</div>
</div>
{/* Images */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-amber-50 rounded-lg">
<ImageIcon className="w-5 h-5 text-amber-600" />
@@ -258,9 +262,9 @@ export function Dashboard() {
<span className="text-xs font-medium text-gray-600">{imagePercentage}%</span>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-600">Images Downloaded</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.products?.with_images?.toLocaleString() || 0}</p>
<div className="mt-3">
<p className="text-xs sm:text-sm font-medium text-gray-600">Images</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.products?.with_images?.toLocaleString() || 0}</p>
<div className="mt-2 sm:mt-3">
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-amber-500 h-1.5 rounded-full transition-all"
@@ -272,7 +276,7 @@ export function Dashboard() {
</div>
{/* Snapshots (24h) */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-cyan-50 rounded-lg">
<Activity className="w-5 h-5 text-cyan-600" />
@@ -280,14 +284,14 @@ export function Dashboard() {
<Clock className="w-4 h-4 text-gray-400" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-600">Snapshots (24h)</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.clicks?.clicks_24h?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500">Product snapshots created</p>
<p className="text-xs sm:text-sm font-medium text-gray-600">Snapshots (24h)</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.clicks?.clicks_24h?.toLocaleString() || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">Product snapshots</p>
</div>
</div>
{/* Brands */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="flex items-center justify-between mb-4">
<div className="p-2 bg-indigo-50 rounded-lg">
<Tag className="w-5 h-5 text-indigo-600" />
@@ -295,22 +299,22 @@ export function Dashboard() {
<Activity className="w-4 h-4 text-gray-400" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-gray-600">Brands</p>
<p className="text-3xl font-semibold text-gray-900">{stats?.brands?.total || stats?.products?.unique_brands || 0}</p>
<p className="text-xs text-gray-500">Unique brands tracked</p>
<p className="text-xs sm:text-sm font-medium text-gray-600">Brands</p>
<p className="text-2xl sm:text-3xl font-semibold text-gray-900">{stats?.brands?.total || stats?.products?.unique_brands || 0}</p>
<p className="text-xs text-gray-500 hidden sm:block">Unique brands</p>
</div>
</div>
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Product Growth Chart */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="mb-6">
<h3 className="text-base font-semibold text-gray-900">Product Growth</h3>
<p className="text-sm text-gray-500 mt-1">Weekly product count trend</p>
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="mb-4 sm:mb-6">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Product Growth</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Weekly product count trend</p>
</div>
<ResponsiveContainer width="100%" height={200}>
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={productTrendData}>
<defs>
<linearGradient id="productGradient" x1="0" y1="0" x2="0" y2="1">
@@ -348,12 +352,12 @@ export function Dashboard() {
</div>
{/* Scrape Activity Chart */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="mb-6">
<h3 className="text-base font-semibold text-gray-900">Scrape Activity</h3>
<p className="text-sm text-gray-500 mt-1">Scrapes over the last 24 hours</p>
<div className="bg-white rounded-xl border border-gray-200 p-4 sm:p-6">
<div className="mb-4 sm:mb-6">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Scrape Activity</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Scrapes over the last 24 hours</p>
</div>
<ResponsiveContainer width="100%" height={200}>
<ResponsiveContainer width="100%" height={180}>
<LineChart data={scrapeTrendData}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis
@@ -386,20 +390,20 @@ export function Dashboard() {
</div>
{/* Activity Lists */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
{/* Recent Scrapes */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-base font-semibold text-gray-900">Recent Scrapes</h3>
<p className="text-sm text-gray-500 mt-1">Latest data collection activities</p>
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Recent Scrapes</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Latest data collection activities</p>
</div>
<div className="divide-y divide-gray-100">
{activity?.recent_scrapes?.length > 0 ? (
activity.recent_scrapes.slice(0, 5).map((scrape: any, i: number) => (
<div key={i} className="px-6 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div key={i} className="px-4 sm:px-6 py-3 sm:py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{scrape.name}</p>
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">{scrape.name}</p>
<p className="text-xs text-gray-500 mt-1">
{new Date(scrape.last_scraped_at).toLocaleString('en-US', {
month: 'short',
@@ -409,18 +413,18 @@ export function Dashboard() {
})}
</p>
</div>
<div className="ml-4 flex-shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{scrape.product_count} products
<div className="flex-shrink-0">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{scrape.product_count}
</span>
</div>
</div>
</div>
))
) : (
<div className="px-6 py-12 text-center">
<Activity className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No recent scrapes</p>
<div className="px-4 sm:px-6 py-8 sm:py-12 text-center">
<Activity className="w-6 h-6 sm:w-8 sm:h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs sm:text-sm text-gray-500">No recent scrapes</p>
</div>
)}
</div>
@@ -428,22 +432,22 @@ export function Dashboard() {
{/* Recent Products */}
<div className="bg-white rounded-xl border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-base font-semibold text-gray-900">Recent Products</h3>
<p className="text-sm text-gray-500 mt-1">Newly added to inventory</p>
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-gray-200">
<h3 className="text-sm sm:text-base font-semibold text-gray-900">Recent Products</h3>
<p className="text-xs sm:text-sm text-gray-500 mt-1">Newly added to inventory</p>
</div>
<div className="divide-y divide-gray-100">
{activity?.recent_products?.length > 0 ? (
activity.recent_products.slice(0, 5).map((product: any, i: number) => (
<div key={i} className="px-6 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div key={i} className="px-4 sm:px-6 py-3 sm:py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{product.name}</p>
<p className="text-xs text-gray-500 mt-1">{product.store_name}</p>
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">{product.name}</p>
<p className="text-xs text-gray-500 mt-1 truncate">{product.store_name}</p>
</div>
{product.price && (
<div className="ml-4 flex-shrink-0">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700">
<div className="flex-shrink-0">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-emerald-50 text-emerald-700">
${product.price}
</span>
</div>
@@ -452,9 +456,9 @@ export function Dashboard() {
</div>
))
) : (
<div className="px-6 py-12 text-center">
<Package className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-sm text-gray-500">No recent products</p>
<div className="px-4 sm:px-6 py-8 sm:py-12 text-center">
<Package className="w-6 h-6 sm:w-8 sm:h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs sm:text-sm text-gray-500">No recent products</p>
</div>
)}
</div>

525
cannaiq/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,525 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
TrendingUp,
BarChart3,
Package,
MapPin,
DollarSign,
Store,
Globe,
Bell,
ArrowRight,
Check,
Zap,
Database,
Code,
Building2,
ShoppingBag,
LineChart
} from 'lucide-react';
import { api } from '../lib/api';
interface VersionInfo {
build_version: string;
git_sha: string;
build_time: string;
image_tag: string;
}
// Reusable Logo component matching login page
function Logo({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) {
const sizes = {
sm: { box: 'w-8 h-8', icon: 'w-5 h-5', text: 'text-lg' },
md: { box: 'w-10 h-10', icon: 'w-6 h-6', text: 'text-2xl' },
lg: { box: 'w-12 h-12', icon: 'w-8 h-8', text: 'text-3xl' }
};
const s = sizes[size];
return (
<div className="flex items-center gap-3">
<div className={`${s.box} bg-white rounded-lg flex items-center justify-center`}>
<svg viewBox="0 0 24 24" className={`${s.icon} text-emerald-600`} fill="currentColor">
<path d="M12 2C8.5 2 5.5 3.5 3.5 6L12 12L20.5 6C18.5 3.5 15.5 2 12 2Z" />
<path d="M3.5 6C2 8 1 10.5 1 13C1 18.5 6 22 12 22C18 22 23 18.5 23 13C23 10.5 22 8 20.5 6L12 12L3.5 6Z" opacity="0.7" />
</svg>
</div>
<span className={`text-white ${s.text} font-bold`}>Canna IQ</span>
</div>
);
}
// Feature card matching login page style
function FeatureCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
return (
<div className="bg-white rounded-xl shadow-lg p-6 hover:shadow-xl transition-shadow">
<div className="w-12 h-12 bg-emerald-100 rounded-xl flex items-center justify-center mb-4">
{icon}
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
<p className="text-gray-600 text-sm">{description}</p>
</div>
);
}
// Stat card for coverage section
function StatCard({ value, label }: { value: string; label: string }) {
return (
<div className="bg-white/10 backdrop-blur-sm rounded-xl p-6 text-center">
<div className="text-3xl font-bold text-white mb-1">{value}</div>
<div className="text-white/70 text-sm">{label}</div>
</div>
);
}
// Badge pill component
function Badge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-white/10 backdrop-blur-sm rounded-full text-white/90 text-sm">
<Check className="w-3.5 h-3.5 text-emerald-300" />
{children}
</span>
);
}
// Use case card
function UseCaseCard({ icon, title, bullets }: { icon: React.ReactNode; title: string; bullets: string[] }) {
return (
<div className="bg-white rounded-xl shadow-lg p-6 flex-1">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-emerald-100 rounded-lg flex items-center justify-center">
{icon}
</div>
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
</div>
<ul className="space-y-3">
{bullets.map((bullet, i) => (
<li key={i} className="flex items-start gap-2 text-gray-600 text-sm">
<Check className="w-4 h-4 text-emerald-500 mt-0.5 flex-shrink-0" />
{bullet}
</li>
))}
</ul>
</div>
);
}
// Step component for "How it works"
function Step({ number, title, description }: { number: number; title: string; description: string }) {
return (
<div className="flex-1 text-center">
<div className="w-10 h-10 bg-emerald-600 rounded-full flex items-center justify-center text-white font-bold mx-auto mb-3">
{number}
</div>
<h4 className="font-semibold text-gray-900 mb-1">{title}</h4>
<p className="text-gray-600 text-sm">{description}</p>
</div>
);
}
// Mock dashboard preview card
function DashboardPreview() {
return (
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-4 shadow-2xl">
{/* Header bar */}
<div className="flex items-center gap-2 mb-4">
<div className="w-3 h-3 rounded-full bg-red-400" />
<div className="w-3 h-3 rounded-full bg-yellow-400" />
<div className="w-3 h-3 rounded-full bg-green-400" />
<div className="flex-1 bg-white/10 rounded h-4 ml-2" />
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-2 mb-4">
<div className="bg-white/10 rounded-lg p-3">
<div className="text-xs text-white/60 mb-1">Products</div>
<div className="text-lg font-bold text-white">47,832</div>
</div>
<div className="bg-white/10 rounded-lg p-3">
<div className="text-xs text-white/60 mb-1">Stores</div>
<div className="text-lg font-bold text-white">1,248</div>
</div>
<div className="bg-white/10 rounded-lg p-3">
<div className="text-xs text-white/60 mb-1">Brands</div>
<div className="text-lg font-bold text-white">892</div>
</div>
</div>
{/* Mini chart placeholder */}
<div className="bg-white/10 rounded-lg p-3 mb-4">
<div className="text-xs text-white/60 mb-2">Price Trends</div>
<div className="flex items-end gap-1 h-12">
{[40, 55, 45, 60, 50, 70, 65, 75, 68, 80, 72, 85].map((h, i) => (
<div
key={i}
className="flex-1 bg-emerald-400/60 rounded-t"
style={{ height: `${h}%` }}
/>
))}
</div>
</div>
{/* Map placeholder */}
<div className="bg-white/10 rounded-lg p-3">
<div className="text-xs text-white/60 mb-2">Coverage Map</div>
<div className="grid grid-cols-5 gap-1">
{['WA', 'OR', 'CA', 'NV', 'AZ', 'CO', 'MI', 'IL', 'MA', 'NY'].map((state) => (
<div key={state} className="bg-emerald-500/40 rounded text-center py-1 text-xs text-white/80">
{state}
</div>
))}
</div>
</div>
</div>
);
}
export function Home() {
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
useEffect(() => {
const fetchVersion = async () => {
try {
const data = await api.getVersion();
setVersionInfo(data);
} catch (error) {
console.error('Failed to fetch version info:', error);
}
};
fetchVersion();
}, []);
return (
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<nav className="bg-gradient-to-r from-emerald-600 via-emerald-700 to-teal-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Logo size="sm" />
<div className="flex items-center gap-4">
<Link
to="/login"
className="text-white/90 hover:text-white text-sm font-medium transition-colors"
>
Sign in
</Link>
<a
href="mailto:hello@cannaiq.co"
className="bg-white text-emerald-700 px-4 py-2 rounded-lg text-sm font-semibold hover:bg-gray-100 transition-colors"
>
Request a demo
</a>
</div>
</div>
</div>
</nav>
{/* Hero Section */}
<section className="relative bg-gradient-to-br from-emerald-600 via-emerald-700 to-teal-800 overflow-hidden">
{/* Decorative circles matching login page */}
<div className="absolute top-[-100px] right-[-100px] w-[400px] h-[400px] bg-white/5 rounded-full" />
<div className="absolute bottom-[-150px] left-[-100px] w-[500px] h-[500px] bg-white/5 rounded-full" />
<div className="absolute top-[50%] right-[20%] w-[300px] h-[300px] bg-white/5 rounded-full" />
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 lg:py-24">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left: Text content */}
<div>
<h1 className="text-4xl lg:text-5xl font-bold text-white leading-tight mb-6">
Real-time cannabis intelligence for the U.S. and Canada.
</h1>
<p className="text-lg text-white/80 mb-8 max-w-xl">
Track products, pricing, availability, and competitor movement across every legal marketpowered by live dispensary menus.
</p>
{/* CTAs */}
<div className="flex flex-wrap gap-4 mb-8">
<Link
to="/login"
className="bg-white text-emerald-700 px-6 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors shadow-lg flex items-center gap-2"
>
Log in to CannaiQ
<ArrowRight className="w-4 h-4" />
</Link>
<a
href="mailto:hello@cannaiq.co"
className="border-2 border-white text-white px-6 py-3 rounded-lg font-semibold hover:bg-white hover:text-emerald-700 transition-colors"
>
Request a demo
</a>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-3">
<Badge>U.S. + Canada coverage</Badge>
<Badge>Live menu data</Badge>
<Badge>Brand & retailer views</Badge>
<Badge>Price and promo tracking</Badge>
</div>
</div>
{/* Right: Dashboard preview */}
<div className="hidden lg:block">
<DashboardPreview />
</div>
</div>
</div>
</section>
{/* Feature Grid Section */}
<section className="py-16 lg:py-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">What CannaiQ gives you</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Everything you need to understand and act on cannabis market dynamics.
</p>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
<FeatureCard
icon={<Package className="w-6 h-6 text-emerald-600" />}
title="Live product tracking"
description="See what's on the shelf right now—products, SKUs, and stock status pulled from live menus."
/>
<FeatureCard
icon={<DollarSign className="w-6 h-6 text-emerald-600" />}
title="Price & promo monitoring"
description="Compare pricing, discounts, and promos across markets to see who's racing to the bottom and where you're leaving margin on the table."
/>
<FeatureCard
icon={<TrendingUp className="w-6 h-6 text-emerald-600" />}
title="Brand penetration & share"
description="Track where each brand is listed, how deep their presence goes in each store, and how your portfolio compares."
/>
<FeatureCard
icon={<Store className="w-6 h-6 text-emerald-600" />}
title="Store-level intelligence"
description="Drill into individual dispensaries to see assortment, pricing, and who they're favoring in each category."
/>
<FeatureCard
icon={<Globe className="w-6 h-6 text-emerald-600" />}
title="Multi-state coverage (U.S. + Canada)"
description="Flip between U.S. and Canadian markets and compare how brands and categories perform across borders."
/>
<FeatureCard
icon={<Bell className="w-6 h-6 text-emerald-600" />}
title="Alerts & change detection"
description="Get notified when products appear, disappear, or change price in key stores. Coming soon."
/>
</div>
</div>
</section>
{/* Coverage & Scale Section */}
<section className="relative bg-gradient-to-br from-emerald-600 via-emerald-700 to-teal-800 py-16 lg:py-24 overflow-hidden">
<div className="absolute top-[-50px] left-[-50px] w-[300px] h-[300px] bg-white/5 rounded-full" />
<div className="absolute bottom-[-80px] right-[-80px] w-[400px] h-[400px] bg-white/5 rounded-full" />
<div className="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-white mb-4">Built for North American cannabis markets</h2>
<p className="text-white/80 max-w-2xl mx-auto">
CannaiQ continuously monitors licensed cannabis menus across the U.S. and Canada, normalizing brand and product data so you can compare markets, categories, and competitors in one place.
</p>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard value="2 countries" label="U.S. & Canada tracked" />
<StatCard value="Hundreds" label="Live dispensary menus" />
<StatCard value="Tens of thousands" label="Normalized SKUs" />
<StatCard value="Daily updates" label="Fresh crawls & snapshots" />
</div>
</div>
</section>
{/* Use Cases Section */}
<section className="py-16 lg:py-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">Built for brands and retailers</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Whether you're placing products or stocking shelves, CannaiQ gives you the visibility you need.
</p>
</div>
<div className="grid md:grid-cols-2 gap-6">
<UseCaseCard
icon={<Building2 className="w-5 h-5 text-emerald-600" />}
title="For Brands"
bullets={[
"See where your SKUs are listed—and where they're missing.",
"Compare your pricing and promos to competing brands.",
"Find whitespace in categories, formats, and price points."
]}
/>
<UseCaseCard
icon={<ShoppingBag className="w-5 h-5 text-emerald-600" />}
title="For Retailers"
bullets={[
"Benchmark your assortment and pricing against nearby stores.",
"Identify gaps in key categories and formats.",
"Track which brands are gaining or losing shelf space."
]}
/>
</div>
</div>
</section>
{/* How It Works Section */}
<section className="py-16 lg:py-24 px-4 sm:px-6 lg:px-8 bg-gray-100">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">How CannaiQ works</h2>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-8">
<Step
number={1}
title="We crawl live menus"
description="Automated workers pull product, price, and availability data from dispensary menus."
/>
<Step
number={2}
title="We normalize brands & SKUs"
description="Products are mapped and cleaned so you can compare across stores, states, and platforms."
/>
<Step
number={3}
title="We surface intelligence"
description="Dashboards and APIs highlight trends, penetration, and competitive movement."
/>
<Step
number={4}
title="You act faster"
description="Make confident decisions on pricing, promos, and distribution."
/>
</div>
</div>
</section>
{/* Integration Section */}
<section className="py-16 lg:py-24 px-4 sm:px-6 lg:px-8">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-4">Data where you need it</h2>
<p className="text-gray-600">
Use CannaiQ in the browser, or plug it into your own tools via API and WordPress.
</p>
</div>
<div className="bg-white rounded-2xl shadow-lg p-8">
<div className="grid md:grid-cols-2 gap-8">
<div>
<h3 className="font-semibold text-gray-900 mb-4 flex items-center gap-2">
<Database className="w-5 h-5 text-emerald-600" />
Available integrations
</h3>
<ul className="space-y-3">
<li className="flex items-start gap-2 text-gray-600">
<Check className="w-4 h-4 text-emerald-500 mt-0.5" />
Admin dashboard at <code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">/admin</code> for deep drill-downs
</li>
<li className="flex items-start gap-2 text-gray-600">
<Check className="w-4 h-4 text-emerald-500 mt-0.5" />
WordPress plugin for displaying menus on your site
</li>
<li className="flex items-start gap-2 text-gray-600">
<Check className="w-4 h-4 text-emerald-500 mt-0.5" />
API-ready architecture for custom integrations
</li>
</ul>
</div>
<div className="flex flex-col gap-4">
<Link
to="/admin"
className="flex items-center justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors"
>
<BarChart3 className="w-5 h-5" />
Go to /admin
</Link>
<a
href="/downloads/crawlsy-menus-v1.3.0.zip"
className="flex items-center justify-center gap-2 border-2 border-emerald-600 text-emerald-700 font-semibold py-3 px-6 rounded-lg hover:bg-emerald-50 transition-colors"
>
<Code className="w-5 h-5" />
Download WordPress Plugin
</a>
</div>
</div>
</div>
</div>
</section>
{/* Final CTA Section */}
<section className="bg-gradient-to-r from-emerald-600 via-emerald-700 to-teal-800 py-16 lg:py-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold text-white mb-4">Ready to see your market clearly?</h2>
<p className="text-white/80 mb-8 max-w-xl mx-auto">
Log in if you're already onboarded, or request access to start exploring live data.
</p>
<div className="flex flex-wrap justify-center gap-4">
<Link
to="/login"
className="bg-white text-emerald-700 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors shadow-lg flex items-center gap-2"
>
Log in to CannaiQ
<ArrowRight className="w-4 h-4" />
</Link>
<a
href="mailto:hello@cannaiq.co"
className="border-2 border-white text-white px-8 py-3 rounded-lg font-semibold hover:bg-white hover:text-emerald-700 transition-colors"
>
Request a demo
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-emerald-600 rounded-lg flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-6 h-6 text-white" fill="currentColor">
<path d="M12 2C8.5 2 5.5 3.5 3.5 6L12 12L20.5 6C18.5 3.5 15.5 2 12 2Z" />
<path d="M3.5 6C2 8 1 10.5 1 13C1 18.5 6 22 12 22C18 22 23 18.5 23 13C23 10.5 22 8 20.5 6L12 12L3.5 6Z" opacity="0.7" />
</svg>
</div>
<div>
<div className="text-white font-bold text-lg">Canna IQ</div>
<div className="text-gray-400 text-sm">Cannabis Market Intelligence</div>
</div>
</div>
<div className="flex items-center gap-6 text-gray-400 text-sm">
<Link to="/login" className="hover:text-white transition-colors">Sign in</Link>
<a href="mailto:hello@cannaiq.co" className="hover:text-white transition-colors">Contact</a>
<a href="/downloads/crawlsy-menus-v1.3.0.zip" className="hover:text-white transition-colors">WordPress Plugin</a>
</div>
</div>
<div className="mt-8 pt-8 border-t border-gray-800 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-gray-500 text-sm">
&copy; {new Date().getFullYear()} CannaiQ. All rights reserved.
</p>
{versionInfo && (
<div className="text-right">
<p className="text-xs text-gray-500">
{versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)})
</p>
<p className="text-xs text-gray-600">
{versionInfo.image_tag}
</p>
</div>
)}
</div>
</div>
</footer>
</div>
);
}
export default Home;

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { trackProductClick } from '../lib/analytics';
import {
Building2,
MapPin,
@@ -242,7 +243,18 @@ export function IntelligenceBrands() {
</tr>
) : (
filteredBrands.map((brand) => (
<tr key={brand.brandName} className="hover:bg-gray-50">
<tr
key={brand.brandName}
className="hover:bg-gray-50 cursor-pointer"
onClick={() => {
trackProductClick({
productId: brand.brandName, // Use brand name as identifier
brandId: brand.brandName,
action: 'view',
source: 'brands_intelligence'
});
}}
>
<td>
<span className="font-medium">{brand.brandName}</span>
</td>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { useStateFilter } from '../hooks/useStateFilter';
import {
MapPin,
Building2,
@@ -12,6 +13,7 @@ import {
Clock,
Activity,
ChevronDown,
ExternalLink,
} from 'lucide-react';
interface StoreActivity {
@@ -28,28 +30,28 @@ interface StoreActivity {
export function IntelligenceStores() {
const navigate = useNavigate();
const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter();
const [stores, setStores] = useState<StoreActivity[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [stateFilter, setStateFilter] = useState('all');
const [states, setStates] = useState<string[]>([]);
const [localStates, setLocalStates] = useState<string[]>([]);
useEffect(() => {
loadStores();
}, [stateFilter]);
}, [selectedState]);
const loadStores = async () => {
try {
setLoading(true);
const data = await api.getIntelligenceStoreActivity({
state: stateFilter !== 'all' ? stateFilter : undefined,
state: stateParam,
limit: 500,
});
setStores(data.stores || []);
// Extract unique states
// Extract unique states from response for dropdown counts
const uniqueStates = [...new Set(data.stores.map((s: StoreActivity) => s.state))].sort();
setStates(uniqueStates);
setLocalStates(uniqueStates);
} catch (error) {
console.error('Failed to load stores:', error);
} finally {
@@ -139,8 +141,8 @@ export function IntelligenceStores() {
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-4 gap-4">
{/* Summary Cards - Responsive: 2→4 columns */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center gap-3">
<MapPin className="w-8 h-8 text-blue-500" />
@@ -179,8 +181,8 @@ export function IntelligenceStores() {
</div>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
{/* Filters - Responsive with wrapping */}
<div className="flex flex-wrap items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
@@ -193,18 +195,18 @@ export function IntelligenceStores() {
</div>
<div className="dropdown">
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
{stateFilter === 'all' ? 'All States' : stateFilter}
{stateLabel}
<ChevronDown className="w-4 h-4" />
</button>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-40 max-h-60 overflow-y-auto">
<li>
<a onClick={() => setStateFilter('all')} className={stateFilter === 'all' ? 'active' : ''}>
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
All States
</a>
</li>
{states.map(state => (
{localStates.map(state => (
<li key={state}>
<a onClick={() => setStateFilter(state)} className={stateFilter === state ? 'active' : ''}>
<a onClick={() => setSelectedState(state)} className={selectedState === state ? 'active' : ''}>
{state}
</a>
</li>
@@ -229,12 +231,13 @@ export function IntelligenceStores() {
<th className="text-center">Snapshots</th>
<th>Last Crawl</th>
<th>Frequency</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{filteredStores.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-gray-500">
<td colSpan={8} className="text-center py-8 text-gray-500">
No stores found
</td>
</tr>
@@ -272,6 +275,19 @@ export function IntelligenceStores() {
<td>
{getCrawlFrequencyBadge(store.crawlFrequencyHours)}
</td>
<td>
<button
className="btn btn-xs btn-primary gap-1"
title="View Store Dashboard"
onClick={(e) => {
e.stopPropagation();
navigate(`/stores/list/${store.id}`);
}}
>
<ExternalLink className="w-3 h-3" />
Dashboard
</button>
</td>
</tr>
))
)}

View File

@@ -1,7 +1,15 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { TrendingUp, BarChart3, Package } from 'lucide-react';
import { api } from '../lib/api';
interface VersionInfo {
build_version: string;
git_sha: string;
build_time: string;
image_tag: string;
}
interface FeatureCardProps {
icon: React.ReactNode;
@@ -28,9 +36,22 @@ export function Login() {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
const navigate = useNavigate();
const login = useAuthStore((state) => state.login);
useEffect(() => {
const fetchVersion = async () => {
try {
const data = await api.getVersion();
setVersionInfo(data);
} catch (error) {
console.error('Failed to fetch version info:', error);
}
};
fetchVersion();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
@@ -70,7 +91,7 @@ export function Login() {
Cannabis Market<br />Intelligence Platform
</h1>
<p className="text-white/80 text-lg mt-4 max-w-md">
Real-time competitive insights for Arizona dispensaries. Track products, prices, and market trends.
Real-time product and pricing visibility across the entire U.S. and Canadian cannabis marketsbuilt for competitive clarity.
</p>
</div>
@@ -194,6 +215,17 @@ export function Login() {
</p>
</div>
{/* Version Info */}
{versionInfo && (
<div className="mt-4 text-center">
<p className="text-xs text-gray-400">
{versionInfo.build_version} ({versionInfo.git_sha.slice(0, 7)})
</p>
<p className="text-xs text-gray-300 mt-0.5">
{versionInfo.image_tag}
</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { useStateFilter } from '../hooks/useStateFilter';
import {
Package,
Building2,
@@ -17,9 +18,27 @@ import {
TrendingUp,
TrendingDown,
Minus,
Activity,
BarChart3,
} from 'lucide-react';
import { StoreOrchestratorPanel } from '../components/StoreOrchestratorPanel';
interface CrawlHealth {
status: 'ok' | 'degraded' | 'stale' | 'error';
last_run: string | null;
runs_last_24h: number;
stores_with_recent_crawl: number;
stores_total: number;
stale_stores: number;
}
interface AnalyticsHealth {
status: 'ok' | 'degraded' | 'stale' | 'error';
last_aggregate: string | null;
daily_runs_last_7d: number;
missing_days: number;
}
interface OrchestratorMetrics {
total_products: number;
total_brands: number;
@@ -61,15 +80,17 @@ interface StoreInfo {
export function OrchestratorDashboard() {
const navigate = useNavigate();
const { selectedState, setSelectedState, stateParam, stateLabel, isAllStates } = useStateFilter();
const [metrics, setMetrics] = useState<OrchestratorMetrics | null>(null);
const [states, setStates] = useState<StateInfo[]>([]);
const [stores, setStores] = useState<StoreInfo[]>([]);
const [totalStores, setTotalStores] = useState(0);
const [loading, setLoading] = useState(true);
const [selectedState, setSelectedState] = useState<string>('all');
const [autoRefresh, setAutoRefresh] = useState(true);
const [selectedStore, setSelectedStore] = useState<StoreInfo | null>(null);
const [panelTab, setPanelTab] = useState<'control' | 'trace' | 'profile' | 'module' | 'debug'>('control');
const [crawlHealth, setCrawlHealth] = useState<CrawlHealth | null>(null);
const [analyticsHealth, setAnalyticsHealth] = useState<AnalyticsHealth | null>(null);
useEffect(() => {
loadData();
@@ -82,16 +103,21 @@ export function OrchestratorDashboard() {
const loadData = async () => {
try {
const [metricsData, statesData, storesData] = await Promise.all([
// stateParam is undefined for "All States", or the state code for specific state
const [metricsData, statesData, storesData, crawlHealthData, analyticsHealthData] = await Promise.all([
api.getOrchestratorMetrics(),
api.getOrchestratorStates(),
api.getOrchestratorStores({ state: selectedState, limit: 200 }),
api.getOrchestratorStores({ state: stateParam, limit: 200 }),
api.getHealthCrawls().catch(() => null),
api.getHealthAnalytics().catch(() => null),
]);
setMetrics(metricsData);
setStates(statesData.states || []);
setStores(storesData.stores || []);
setTotalStores(storesData.total || 0);
setCrawlHealth(crawlHealthData);
setAnalyticsHealth(analyticsHealthData);
} catch (error) {
console.error('Failed to load orchestrator data:', error);
} finally {
@@ -161,6 +187,21 @@ export function OrchestratorDashboard() {
}
};
const getHealthStatusStyle = (status: string | undefined) => {
switch (status) {
case 'ok':
return { bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', icon: 'text-green-500' };
case 'degraded':
return { bg: 'bg-yellow-50', border: 'border-yellow-200', text: 'text-yellow-700', icon: 'text-yellow-500' };
case 'stale':
return { bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', icon: 'text-orange-500' };
case 'error':
return { bg: 'bg-red-50', border: 'border-red-200', text: 'text-red-700', icon: 'text-red-500' };
default:
return { bg: 'bg-gray-50', border: 'border-gray-200', text: 'text-gray-700', icon: 'text-gray-400' };
}
};
const formatTimeAgo = (dateStr: string | null) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
@@ -218,9 +259,9 @@ export function OrchestratorDashboard() {
</div>
</div>
{/* Metrics Cards - Clickable */}
{/* Metrics Cards - Clickable - Responsive: 2→3→4→7 columns */}
{metrics && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-7 gap-3 md:gap-4">
<div
onClick={() => navigate('/admin/orchestrator/products')}
className="bg-white rounded-lg border border-gray-200 p-4 cursor-pointer hover:bg-blue-50 hover:border-blue-300 transition-colors"
@@ -314,17 +355,88 @@ export function OrchestratorDashboard() {
</div>
)}
{/* State Selector */}
<div className="flex items-center gap-4">
{/* Health Status Tiles */}
{(crawlHealth || analyticsHealth) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Crawl Health Tile */}
{crawlHealth && (
<div className={`rounded-lg border p-4 ${getHealthStatusStyle(crawlHealth.status).bg} ${getHealthStatusStyle(crawlHealth.status).border}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className={`w-5 h-5 ${getHealthStatusStyle(crawlHealth.status).icon}`} />
<h3 className="font-semibold text-gray-900">Crawl Status</h3>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${getHealthStatusStyle(crawlHealth.status).text} ${getHealthStatusStyle(crawlHealth.status).bg}`}>
{crawlHealth.status.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Last Run</p>
<p className="font-medium">{formatTimeAgo(crawlHealth.last_run)}</p>
</div>
<div>
<p className="text-gray-500">Runs (24h)</p>
<p className="font-medium">{crawlHealth.runs_last_24h}</p>
</div>
<div>
<p className="text-gray-500">Fresh Stores</p>
<p className="font-medium">{crawlHealth.stores_with_recent_crawl} / {crawlHealth.stores_total}</p>
</div>
<div>
<p className="text-gray-500">Stale Stores</p>
<p className={`font-medium ${crawlHealth.stale_stores > 0 ? 'text-orange-600' : 'text-green-600'}`}>
{crawlHealth.stale_stores}
</p>
</div>
</div>
</div>
)}
{/* Analytics Health Tile */}
{analyticsHealth && (
<div className={`rounded-lg border p-4 ${getHealthStatusStyle(analyticsHealth.status).bg} ${getHealthStatusStyle(analyticsHealth.status).border}`}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<BarChart3 className={`w-5 h-5 ${getHealthStatusStyle(analyticsHealth.status).icon}`} />
<h3 className="font-semibold text-gray-900">Analytics Status</h3>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${getHealthStatusStyle(analyticsHealth.status).text} ${getHealthStatusStyle(analyticsHealth.status).bg}`}>
{analyticsHealth.status.toUpperCase()}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-500">Last Aggregate</p>
<p className="font-medium">{formatTimeAgo(analyticsHealth.last_aggregate)}</p>
</div>
<div>
<p className="text-gray-500">Runs (7d)</p>
<p className="font-medium">{analyticsHealth.daily_runs_last_7d}</p>
</div>
<div>
<p className="text-gray-500">Missing Days</p>
<p className={`font-medium ${analyticsHealth.missing_days > 0 ? 'text-orange-600' : 'text-green-600'}`}>
{analyticsHealth.missing_days}
</p>
</div>
</div>
</div>
)}
</div>
)}
{/* State Selector - Uses global state store */}
<div className="flex flex-wrap items-center gap-4">
<label className="text-sm font-medium text-gray-700">Filter by State:</label>
<div className="dropdown">
<button tabIndex={0} className="btn btn-sm btn-outline gap-2">
{selectedState === 'all' ? 'All States' : selectedState}
{stateLabel}
<ChevronDown className="w-4 h-4" />
</button>
<ul tabIndex={0} className="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52 max-h-60 overflow-y-auto">
<li>
<a onClick={() => setSelectedState('all')} className={selectedState === 'all' ? 'active' : ''}>
<a onClick={() => setSelectedState(null)} className={isAllStates ? 'active' : ''}>
All States ({states.reduce((sum, s) => sum + s.storeCount, 0)})
</a>
</li>
@@ -343,25 +455,25 @@ export function OrchestratorDashboard() {
</span>
</div>
{/* Two-column layout: Store table + Panel */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Two-column layout: Store table + Panel - stacks on mobile */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 lg:gap-6">
{/* Stores Table */}
<div className="lg:col-span-2 bg-white rounded-lg border border-gray-200">
<div className="p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Stores</h2>
<div className="xl:col-span-2 bg-white rounded-lg border border-gray-200">
<div className="p-3 sm:p-4 border-b border-gray-200">
<h2 className="text-base sm:text-lg font-semibold text-gray-900">Stores</h2>
</div>
<div className="overflow-x-auto max-h-[600px] overflow-y-auto">
<div className="overflow-x-auto max-h-[400px] sm:max-h-[500px] lg:max-h-[600px] overflow-y-auto">
<table className="table table-sm w-full">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th>Name</th>
<th>State</th>
<th>Provider</th>
<th>Status</th>
<th>Last Success</th>
<th>Last Failure</th>
<th>Products</th>
<th>Actions</th>
<th className="text-xs">Name</th>
<th className="text-xs hidden sm:table-cell">State</th>
<th className="text-xs hidden md:table-cell">Provider</th>
<th className="text-xs">Status</th>
<th className="text-xs hidden lg:table-cell">Last Success</th>
<th className="text-xs hidden lg:table-cell">Last Failure</th>
<th className="text-xs hidden sm:table-cell">Products</th>
<th className="text-xs">Actions</th>
</tr>
</thead>
<tbody>
@@ -372,27 +484,37 @@ export function OrchestratorDashboard() {
onClick={() => setSelectedStore(store)}
>
<td>
<div className="font-medium text-gray-900">{store.name}</div>
<div className="text-xs text-gray-500">{store.city}</div>
<div className="font-medium text-gray-900 text-xs sm:text-sm">{store.name}</div>
<div className="text-xs text-gray-500">{store.city}<span className="sm:hidden">, {store.state}</span></div>
</td>
<td className="text-sm">{store.state}</td>
<td>
<td className="text-sm hidden sm:table-cell">{store.state}</td>
<td className="hidden md:table-cell">
<span className="badge badge-sm badge-outline">{store.provider_display || 'Menu'}</span>
</td>
<td>{getStatusPill(store.status)}</td>
<td className="text-xs text-green-600">
<td className="text-xs text-green-600 hidden lg:table-cell">
{formatTimeAgo(store.lastSuccessAt)}
</td>
<td className="text-xs text-red-600">
<td className="text-xs text-red-600 hidden lg:table-cell">
{formatTimeAgo(store.lastFailureAt)}
</td>
<td className="text-sm font-mono">
<td className="text-sm font-mono hidden sm:table-cell">
{store.productCount.toLocaleString()}
</td>
<td>
<div className="flex gap-1">
<div className="flex gap-1 flex-wrap">
<button
className="btn btn-xs btn-outline btn-primary"
className="btn btn-xs btn-primary"
title="View Store Dashboard"
onClick={(e) => {
e.stopPropagation();
navigate(`/stores/list/${store.id}`);
}}
>
<Package className="w-3 h-3" />
</button>
<button
className="btn btn-xs btn-outline btn-primary hidden sm:flex"
title="Crawler Control"
onClick={(e) => {
e.stopPropagation();
@@ -403,7 +525,7 @@ export function OrchestratorDashboard() {
<TrendingUp className="w-3 h-3" />
</button>
<button
className="btn btn-xs btn-outline btn-info"
className="btn btn-xs btn-outline btn-info hidden sm:flex"
title="View Trace"
onClick={(e) => {
e.stopPropagation();
@@ -414,7 +536,7 @@ export function OrchestratorDashboard() {
<FileText className="w-3 h-3" />
</button>
<button
className="btn btn-xs btn-outline"
className="btn btn-xs btn-outline hidden md:flex"
title="View Profile"
onClick={(e) => {
e.stopPropagation();
@@ -425,7 +547,7 @@ export function OrchestratorDashboard() {
<Settings className="w-3 h-3" />
</button>
<button
className="btn btn-xs btn-outline"
className="btn btn-xs btn-outline hidden lg:flex"
title="View Crawler Module"
onClick={(e) => {
e.stopPropagation();
@@ -445,7 +567,7 @@ export function OrchestratorDashboard() {
</div>
{/* Side Panel */}
<div className="lg:col-span-1">
<div className="xl:col-span-1">
{selectedStore ? (
<StoreOrchestratorPanel
store={selectedStore}

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api } from '../lib/api';
import { trackProductView } from '../lib/analytics';
import {
Building2,
Phone,
@@ -18,7 +19,7 @@ import {
AlertCircle
} from 'lucide-react';
export function DutchieAZStoreDetail() {
export function StoreDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [summary, setSummary] = useState<any>(null);
@@ -71,7 +72,7 @@ export function DutchieAZStoreDetail() {
const loadStoreSummary = async () => {
setLoading(true);
try {
const data = await api.getDutchieAZStoreSummary(parseInt(id!, 10));
const data = await api.getMarketStoreSummary(parseInt(id!, 10));
setSummary(data);
} catch (error) {
console.error('Failed to load store summary:', error);
@@ -84,7 +85,7 @@ export function DutchieAZStoreDetail() {
if (!id) return;
setProductsLoading(true);
try {
const data = await api.getDutchieAZStoreProducts(parseInt(id, 10), {
const data = await api.getMarketStoreProducts(parseInt(id, 10), {
search: searchQuery || undefined,
stockStatus: stockFilter || undefined,
limit: itemsPerPage,
@@ -103,7 +104,7 @@ export function DutchieAZStoreDetail() {
setShowUpdateDropdown(false);
setIsUpdating(true);
try {
await api.triggerDutchieAZCrawl(parseInt(id!, 10));
await api.triggerStoreCrawl(parseInt(id!, 10));
alert('Crawl started! Refresh the page in a few minutes to see updated data.');
} catch (error) {
console.error('Failed to trigger crawl:', error);
@@ -144,11 +145,11 @@ export function DutchieAZStoreDetail() {
{/* Header */}
<div className="flex items-center justify-between gap-4">
<button
onClick={() => navigate('/az')}
onClick={() => navigate('/stores/list')}
className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="w-4 h-4" />
Back to AZ Stores
Back to Stores
</button>
{/* Update Button */}
@@ -455,7 +456,17 @@ export function DutchieAZStoreDetail() {
</thead>
<tbody>
{products.map((product) => (
<tr key={product.id}>
<tr
key={product.id}
className="cursor-pointer"
onClick={() => {
trackProductView(
String(product.id),
'StoreDetailPage',
{ storeId: id, brandId: product.brand || undefined }
);
}}
>
<td className="whitespace-nowrap">
{product.image_url ? (
<img

View File

@@ -13,7 +13,7 @@ import {
FileText
} from 'lucide-react';
export function DutchieAZStores() {
export function StoresListPage() {
const navigate = useNavigate();
const [stores, setStores] = useState<any[]>([]);
const [totalStores, setTotalStores] = useState<number>(0);
@@ -33,11 +33,11 @@ export function DutchieAZStores() {
setLoading(true);
try {
const [storesData, dashboardData] = await Promise.all([
api.getDutchieAZStores({ limit: 200 }),
api.getDutchieAZDashboard(),
api.getMarketStores({ limit: 200 }),
api.getMarketDashboard(),
]);
setStores(storesData.stores);
setTotalStores(storesData.total);
setTotalStores(storesData.total ?? storesData.stores.length);
setDashboard(dashboardData);
} catch (error) {
console.error('Failed to load data:', error);
@@ -63,9 +63,9 @@ export function DutchieAZStores() {
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dutchie AZ Stores</h1>
<h1 className="text-2xl font-bold text-gray-900">Stores</h1>
<p className="text-sm text-gray-600 mt-1">
Arizona dispensaries using the Dutchie platform - data from the new pipeline
Dispensary store listings with product data
</p>
</div>
<button
@@ -235,7 +235,7 @@ export function DutchieAZStores() {
<td>
<div className="flex gap-2">
<button
onClick={() => navigate(`/az/stores/${store.id}`)}
onClick={() => navigate(`/stores/list/${store.id}`)}
className="btn btn-sm btn-primary"
disabled={!store.platform_dispensary_id}
>

View File

@@ -71,10 +71,10 @@ export function WholesaleAnalytics() {
setLoading(true);
try {
const [dashboardData, storesData, brandsData, categoriesData] = await Promise.all([
api.getDutchieAZDashboard(),
api.getDutchieAZStores({ limit: 200 }),
api.getDutchieAZBrands ? api.getDutchieAZBrands({ limit: 100 }) : Promise.resolve({ brands: [] }),
api.getDutchieAZCategories ? api.getDutchieAZCategories() : Promise.resolve({ categories: [] }),
api.getMarketDashboard(),
api.getMarketStores({ limit: 200 }),
api.getMarketBrands({ limit: 100 }),
api.getMarketCategories(),
]);
setDashboard(dashboardData);
setStores(storesData.stores || []);
@@ -121,7 +121,7 @@ export function WholesaleAnalytics() {
<div>
<h1 className="text-2xl font-bold text-gray-900">Wholesale & Inventory Analytics</h1>
<p className="text-sm text-gray-600 mt-1">
Arizona Dutchie dispensaries data overview
Cannabis dispensary market data overview
</p>
</div>
<button

View File

@@ -122,13 +122,43 @@ export function WorkersDashboard() {
const fetchData = useCallback(async () => {
try {
const [schedulesRes, summaryRes] = await Promise.all([
api.get('/api/dutchie-az/admin/schedules'),
api.get('/api/dutchie-az/monitor/summary'),
const [workersRes, summaryRes] = await Promise.all([
api.get('/api/workers'),
api.get('/api/monitor/summary'),
]);
setSchedules(schedulesRes.data.schedules || []);
setSummary(summaryRes.data);
// Map new API response format to component's expected format
const workers = workersRes.data.workers || [];
setSchedules(workers.map((w: any) => ({
id: w.id,
job_name: w.worker_name,
description: w.description,
worker_name: w.worker_name,
worker_role: w.run_role,
enabled: w.enabled,
base_interval_minutes: w.base_interval_minutes,
jitter_minutes: w.jitter_minutes,
next_run_at: w.next_run_at,
last_run_at: w.last_run_at,
last_status: w.last_status,
job_config: { scope: w.scope },
})));
// Map new summary format
const summary = summaryRes.data.summary;
setSummary({
running_scheduled_jobs: summary?.jobs_24h?.running || 0,
running_dispensary_crawl_jobs: summary?.active_crawl_jobs || 0,
successful_jobs_24h: summary?.jobs_24h?.success || 0,
failed_jobs_24h: summary?.jobs_24h?.failed || 0,
successful_crawls_24h: summary?.jobs_24h?.success || 0,
failed_crawls_24h: summary?.jobs_24h?.failed || 0,
products_found_24h: 0,
snapshots_created_24h: 0,
last_job_started: null,
last_job_completed: null,
nextRuns: [],
});
setError(null);
} catch (err: any) {
setError(err.message || 'Failed to fetch data');
@@ -140,7 +170,7 @@ export function WorkersDashboard() {
const fetchWorkerLogs = useCallback(async (scheduleId: number) => {
setLogsLoading(true);
try {
const res = await api.get(`/api/dutchie-az/admin/schedules/${scheduleId}/logs?limit=20`);
const res = await api.get(`/api/workers/${scheduleId}/logs?limit=20`);
setWorkerLogs(res.data.logs || []);
} catch (err: any) {
console.error('Failed to fetch worker logs:', err);
@@ -175,7 +205,7 @@ export function WorkersDashboard() {
const handleTrigger = async (scheduleId: number) => {
setTriggering(scheduleId);
try {
await api.post(`/api/dutchie-az/admin/schedules/${scheduleId}/trigger`);
await api.post(`/api/workers/${scheduleId}/trigger`);
// Refresh data after trigger
setTimeout(fetchData, 1000);
} catch (err: any) {

View File

@@ -0,0 +1,214 @@
/**
* PagesTab - SEO Pages List
*
* Lists all SEO pages with filtering and state metrics.
* Token-safe: Component is small and focused.
*/
import { useState, useEffect } from 'react';
import { api } from '../../../lib/api';
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2 } from 'lucide-react';
interface SeoPage {
id: number;
type: string;
slug: string;
pageKey: string;
primaryKeyword: string | null;
status: string;
lastGeneratedAt: string | null;
lastReviewedAt: string | null;
metrics?: {
dispensaryCount: number;
productCount: number;
brandCount: number;
};
}
const TYPE_ICONS: Record<string, React.ReactNode> = {
state: <Globe className="w-4 h-4" />,
brand: <Tag className="w-4 h-4" />,
competitor_alternative: <Target className="w-4 h-4" />,
high_intent: <FileText className="w-4 h-4" />,
insight_post: <FileText className="w-4 h-4" />
};
const STATUS_COLORS: Record<string, string> = {
live: 'bg-green-100 text-green-800',
pending_generation: 'bg-yellow-100 text-yellow-800',
draft: 'bg-gray-100 text-gray-800',
stale: 'bg-red-100 text-red-800'
};
export function PagesTab() {
const [pages, setPages] = useState<SeoPage[]>([]);
const [loading, setLoading] = useState(true);
const [typeFilter, setTypeFilter] = useState('all');
const [search, setSearch] = useState('');
const [syncing, setSyncing] = useState(false);
const [generatingId, setGeneratingId] = useState<number | null>(null);
useEffect(() => {
loadPages();
}, [typeFilter, search]);
async function loadPages() {
setLoading(true);
try {
const result = await api.getSeoPages({
type: typeFilter !== 'all' ? typeFilter : undefined,
search: search || undefined
});
setPages(result.pages);
} catch (error) {
console.error('Failed to load SEO pages:', error);
} finally {
setLoading(false);
}
}
async function handleSync() {
setSyncing(true);
try {
await api.syncStatePages();
await loadPages();
} catch (error) {
console.error('Failed to sync state pages:', error);
} finally {
setSyncing(false);
}
}
async function handleGenerate(pageId: number) {
setGeneratingId(pageId);
try {
await api.generateSeoPage(pageId);
await loadPages();
} catch (error) {
console.error('Failed to generate SEO page:', error);
} finally {
setGeneratingId(null);
}
}
return (
<div className="space-y-4">
{/* Filters - responsive wrap */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 border rounded-lg text-sm"
>
<option value="all">All Types</option>
<option value="state">State</option>
<option value="brand">Brand</option>
<option value="competitor_alternative">Competitor</option>
<option value="high_intent">High Intent</option>
<option value="insight_post">Insight</option>
</select>
<input
type="text"
placeholder="Search..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="px-3 py-2 border rounded-lg text-sm w-full sm:w-48 lg:w-64"
/>
</div>
<button
onClick={handleSync}
disabled={syncing}
className="flex items-center justify-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg text-sm hover:bg-emerald-700 disabled:opacity-50 whitespace-nowrap"
>
<RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Sync State Pages</span>
<span className="sm:hidden">Sync</span>
</button>
</div>
{/* Table - responsive with horizontal scroll */}
<div className="bg-white rounded-lg border overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Slug</th>
<th className="hidden md:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Keyword</th>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th className="hidden lg:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Metrics</th>
<th className="hidden sm:table-cell px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Generated</th>
<th className="px-3 sm:px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">Loading...</td>
</tr>
) : pages.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
No pages found. Click "Sync State Pages" to create state pages.
</td>
</tr>
) : (
pages.map((page) => (
<tr key={page.id} className="hover:bg-gray-50">
<td className="px-3 sm:px-4 py-3">
<div className="flex items-center gap-1.5 sm:gap-2">
{TYPE_ICONS[page.type] || <FileText className="w-4 h-4" />}
<span className="text-xs sm:text-sm capitalize whitespace-nowrap">{page.type.replace('_', ' ')}</span>
</div>
</td>
<td className="px-3 sm:px-4 py-3">
<code className="text-xs sm:text-sm text-gray-700 break-all">{page.slug}</code>
</td>
<td className="hidden md:table-cell px-4 py-3 text-sm text-gray-600">
{page.primaryKeyword || '-'}
</td>
<td className="px-3 sm:px-4 py-3">
<span className={`px-2 py-1 rounded-full text-xs whitespace-nowrap ${STATUS_COLORS[page.status] || 'bg-gray-100'}`}>
{page.status.replace('_', ' ')}
</span>
</td>
<td className="hidden lg:table-cell px-4 py-3 text-sm">
{page.metrics ? (
<div className="flex items-center gap-3 text-gray-600 text-xs">
<span title="Dispensaries"><Building2 className="w-3 h-3 inline" /> {page.metrics.dispensaryCount}</span>
<span title="Products">{page.metrics.productCount.toLocaleString()}</span>
<span title="Brands">{page.metrics.brandCount}</span>
</div>
) : '-'}
</td>
<td className="hidden sm:table-cell px-4 py-3 text-sm text-gray-500 whitespace-nowrap">
{page.lastGeneratedAt ? new Date(page.lastGeneratedAt).toLocaleDateString() : 'Never'}
</td>
<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"
>
{generatingId === page.id ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Sparkles className="w-3.5 h-3.5" />
)}
<span className="hidden sm:inline">{generatingId === page.id ? 'Generating...' : 'Generate'}</span>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default PagesTab;

View File

@@ -0,0 +1,111 @@
/**
* SEO Orchestrator - Main Admin Page
*
* Tabbed interface for managing SEO pages.
*/
import { useState, useEffect } from 'react';
import { Layout } from '../../../components/Layout';
import { PagesTab } from './PagesTab';
import { api } from '../../../lib/api';
import { FileText, BarChart2, Settings } from 'lucide-react';
const TABS = [
{ id: 'pages', label: 'Pages', icon: <FileText className="w-4 h-4" /> },
{ id: 'metrics', label: 'State Metrics', icon: <BarChart2 className="w-4 h-4" /> },
{ id: 'settings', label: 'Settings', icon: <Settings className="w-4 h-4" /> }
];
export function SeoOrchestrator() {
const [activeTab, setActiveTab] = useState('pages');
return (
<Layout>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">SEO Orchestrator</h1>
<p className="text-gray-500 mt-1">Manage SEO pages and content generation</p>
</div>
{/* Tabs */}
<div className="border-b">
<nav className="flex gap-4">
{TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab.id
? 'border-emerald-600 text-emerald-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</nav>
</div>
{/* Tab Content */}
<div>
{activeTab === 'pages' && <PagesTab />}
{activeTab === 'metrics' && <StateMetricsTab />}
{activeTab === 'settings' && <SettingsTab />}
</div>
</div>
</Layout>
);
}
// Simple state metrics tab (small component)
function StateMetricsTab() {
const [metrics, setMetrics] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.getStateMetrics().then((r: any) => {
setMetrics(r.states || []);
setLoading(false);
});
}, []);
if (loading) return <div className="text-center py-8 text-gray-500">Loading...</div>;
return (
<div className="bg-white rounded-lg border overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">State</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Dispensaries</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Products</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Brands</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{metrics.map((state) => (
<tr key={state.stateCode} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{state.stateName}</td>
<td className="px-4 py-3 text-right">{state.dispensaryCount.toLocaleString()}</td>
<td className="px-4 py-3 text-right">{state.productCount.toLocaleString()}</td>
<td className="px-4 py-3 text-right">{state.brandCount.toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
// Placeholder settings tab
function SettingsTab() {
return (
<div className="bg-white rounded-lg border p-6">
<h3 className="font-medium text-gray-900 mb-4">SEO Settings</h3>
<p className="text-gray-500">Content generation settings coming soon.</p>
</div>
);
}
export default SeoOrchestrator;

View File

@@ -0,0 +1,310 @@
/**
* SeoPage - Generic Public SEO Page Renderer
*
* Renders SEO content for:
* - /alternatives/:slug (competitor alternatives)
* - /brands/:slug (brand pages)
*
* Content is fetched from backend with automatic sanitization.
* Uses approved enterprise-safe phrasing - NO crawling/scraping terms.
*/
import { useEffect, useState } from 'react';
import { useParams, useLocation, Link } from 'react-router-dom';
interface SeoPageMeta {
title?: string;
description?: string;
h1?: string;
canonicalUrl?: string;
ogTitle?: string;
ogDescription?: string;
ogImageUrl?: string;
}
interface SeoBlock {
type: string;
content?: string;
items?: (string | { value: string; label: string; description?: string })[];
headline?: string;
subheadline?: string;
ctaPrimary?: { text?: string; href?: string };
brands?: { name: string; productCount: number }[];
[key: string]: unknown;
}
interface SeoPageData {
slug: string;
type: string;
meta: SeoPageMeta;
blocks: SeoBlock[];
}
export function SeoPage() {
const location = useLocation();
const { slug } = useParams<{ slug: string }>();
const [data, setData] = useState<SeoPageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Derive full slug from path: /alternatives/hoodie-analytics -> alternatives/hoodie-analytics
const fullSlug = location.pathname.startsWith('/')
? location.pathname.slice(1)
: location.pathname;
useEffect(() => {
async function fetchContent() {
try {
setLoading(true);
setError(null);
const apiUrl = import.meta.env.VITE_API_URL || '';
const response = await fetch(`${apiUrl}/api/seo/public/content?slug=${encodeURIComponent(fullSlug)}`);
if (response.status === 404) {
setError('not_found');
return;
}
if (!response.ok) {
throw new Error('Failed to load page content');
}
const result = await response.json();
setData(result);
// Update document title
if (result.meta?.title) {
document.title = result.meta.title;
}
} catch (err) {
console.error('[SeoPage] Error:', err);
setError('error');
} finally {
setLoading(false);
}
}
if (fullSlug) {
fetchContent();
}
}, [fullSlug]);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
if (error === 'not_found' || !data) {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 py-16 text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-4">Page Coming Soon</h1>
<p className="text-gray-600 mb-8">
This page is being prepared. Check back soon for comprehensive market intelligence.
</p>
<Link
to="/"
className="inline-flex items-center px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
>
Back to Home
</Link>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Error Loading Page</h1>
<p className="text-gray-500">Please try again later.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Hero Section */}
<div className="bg-gradient-to-br from-emerald-600 to-emerald-800 text-white py-16">
<div className="max-w-4xl mx-auto px-4">
<h1 className="text-4xl font-bold mb-4">
{data.meta?.h1 || data.meta?.title || 'CannaiQ Market Intelligence'}
</h1>
{data.meta?.description && (
<p className="text-xl text-white/90">{data.meta.description}</p>
)}
</div>
</div>
{/* Content Blocks */}
<div className="max-w-4xl mx-auto px-4 py-12">
{data.blocks && data.blocks.length > 0 ? (
<div className="space-y-8">
{data.blocks.map((block, index) => (
<RenderBlock key={index} block={block} />
))}
</div>
) : (
<div className="bg-white rounded-xl shadow-lg p-8">
<p className="text-gray-600">
Content for this page is being generated. Our team is continuously updating
market insights to provide you with the latest information.
</p>
</div>
)}
</div>
{/* Footer CTA */}
<div className="bg-gray-100 py-12">
<div className="max-w-4xl mx-auto px-4 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Get Real-Time Cannabis Market Intelligence
</h2>
<p className="text-gray-600 mb-6">
Access continuously updated market data, pricing insights, and brand analytics.
</p>
<Link
to="/login"
className="inline-flex items-center px-6 py-3 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 font-medium"
>
Start Free Trial
</Link>
</div>
</div>
</div>
);
}
function RenderBlock({ block }: { block: SeoBlock }) {
switch (block.type) {
case 'hero':
return (
<div className="bg-gradient-to-br from-emerald-600 to-emerald-800 text-white rounded-xl p-8 -mt-4 mb-8">
{block.headline && <h2 className="text-3xl font-bold mb-2">{block.headline}</h2>}
{block.subheadline && (
<p className="text-lg text-white/90 mb-4">{block.subheadline}</p>
)}
{block.ctaPrimary && (
<Link
to={block.ctaPrimary.href || '/demo'}
className="inline-flex items-center px-4 py-2 bg-white text-emerald-700 rounded-lg hover:bg-emerald-50 font-medium"
>
{block.ctaPrimary.text || 'Learn More'}
</Link>
)}
</div>
);
case 'stats':
const statsItems = (block.items || []).filter(
(item): item is { value: string; label: string; description?: string } => typeof item === 'object'
);
return (
<div className="bg-white rounded-xl shadow-lg p-8">
{block.headline && <h3 className="text-xl font-bold text-gray-900 mb-6">{block.headline}</h3>}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{statsItems.map((item, i) => (
<div key={i} className="text-center">
<div className="text-3xl font-bold text-emerald-600">{item.value}</div>
<div className="text-sm font-medium text-gray-900">{item.label}</div>
{item.description && <div className="text-xs text-gray-500">{item.description}</div>}
</div>
))}
</div>
</div>
);
case 'intro':
return (
<div className="prose prose-lg max-w-none text-gray-700">
<p>{block.content}</p>
</div>
);
case 'topBrands':
const brands = block.brands || [];
return (
<div className="bg-white rounded-xl shadow-lg p-8">
{block.headline && <h3 className="text-xl font-bold text-gray-900 mb-6">{block.headline}</h3>}
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{brands.map((brand, i) => (
<div key={i} className="border rounded-lg p-4">
<div className="font-medium text-gray-900">{brand.name}</div>
<div className="text-sm text-gray-500">{brand.productCount} products</div>
</div>
))}
</div>
</div>
);
case 'heading':
return (
<h2 className="text-2xl font-bold text-gray-900">
{block.content}
</h2>
);
case 'paragraph':
case 'text':
return (
<div className="prose prose-lg max-w-none text-gray-700">
<p>{block.content}</p>
</div>
);
case 'list':
return (
<ul className="list-disc list-inside space-y-2 text-gray-700">
{block.items?.map((item, i) => (
<li key={i}>{typeof item === 'string' ? item : ''}</li>
))}
</ul>
);
case 'feature_grid':
case 'features':
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{block.items?.map((item, i) => (
<div key={i} className="bg-white rounded-lg p-6 shadow">
<p className="text-gray-700">{typeof item === 'string' ? item : ''}</p>
</div>
))}
</div>
);
case 'cta':
return (
<div className="bg-emerald-50 rounded-xl p-8 text-center">
{block.headline && <h3 className="text-xl font-bold text-emerald-900 mb-2">{block.headline}</h3>}
{block.subheadline && <p className="text-emerald-700 mb-4">{block.subheadline}</p>}
{block.content && <p className="text-emerald-800 font-medium mb-4">{block.content}</p>}
<Link
to={block.ctaPrimary?.href || '/demo'}
className="inline-flex items-center px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
>
{block.ctaPrimary?.text || 'Learn More'}
</Link>
</div>
);
default:
// Fallback for unknown block types
if (block.content) {
return (
<div className="text-gray-700">
<p>{block.content}</p>
</div>
);
}
return null;
}
}
export default SeoPage;

View File

@@ -0,0 +1,140 @@
/**
* StatePage - Public SEO State Page
*
* Displays state market data with live metrics.
* NO crawling/scraping language - uses "continuously updated"
*/
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { api } from '../../lib/api';
import { Building2, Package, Tag, TrendingUp } from 'lucide-react';
interface StateMetrics {
stateCode: string;
stateName: string;
dispensaryCount: number;
productCount: number;
brandCount: number;
}
export function StatePage() {
const { stateSlug } = useParams<{ stateSlug: string }>();
const [metrics, setMetrics] = useState<StateMetrics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadMetrics() {
try {
const result = await api.getStateMetrics();
// Find state by slug (convert slug like "arizona" to state code)
const state = result.states.find((s: StateMetrics) =>
s.stateName.toLowerCase().replace(/\s+/g, '-') === stateSlug?.toLowerCase()
);
setMetrics(state || null);
} catch (error) {
console.error('Failed to load state metrics:', error);
} finally {
setLoading(false);
}
}
loadMetrics();
}, [stateSlug]);
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-gray-500">Loading...</div>
</div>
);
}
if (!metrics) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">State Not Found</h1>
<p className="text-gray-500">We don't have data for this state yet.</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Hero */}
<div className="bg-gradient-to-br from-emerald-600 to-emerald-800 text-white py-16">
<div className="max-w-4xl mx-auto px-4">
<h1 className="text-4xl font-bold mb-4">{metrics.stateName} Cannabis Market Data</h1>
<p className="text-xl text-white/90">
Real-time market intelligence for the {metrics.stateName} cannabis industry
</p>
</div>
</div>
{/* Market Snapshot */}
<div className="max-w-4xl mx-auto px-4 -mt-8">
<div className="bg-white rounded-xl shadow-lg p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-emerald-600" />
Market Snapshot - {metrics.stateName}
</h2>
<div className="grid md:grid-cols-3 gap-6">
<div className="text-center p-4 bg-gray-50 rounded-lg">
<Building2 className="w-8 h-8 text-emerald-600 mx-auto mb-2" />
<div className="text-3xl font-bold text-gray-900">
{metrics.dispensaryCount.toLocaleString()}
</div>
<div className="text-sm text-gray-500">Dispensaries</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<Package className="w-8 h-8 text-emerald-600 mx-auto mb-2" />
<div className="text-3xl font-bold text-gray-900">
{metrics.productCount.toLocaleString()}
</div>
<div className="text-sm text-gray-500">Products</div>
</div>
<div className="text-center p-4 bg-gray-50 rounded-lg">
<Tag className="w-8 h-8 text-emerald-600 mx-auto mb-2" />
<div className="text-3xl font-bold text-gray-900">
{metrics.brandCount.toLocaleString()}
</div>
<div className="text-sm text-gray-500">Brands</div>
</div>
</div>
<p className="mt-6 text-gray-600 text-center">
CannaIQ currently monitors{' '}
<strong>{metrics.dispensaryCount.toLocaleString()}</strong> dispensaries,{' '}
<strong>{metrics.productCount.toLocaleString()}</strong> products, and{' '}
<strong>{metrics.brandCount.toLocaleString()}</strong> brands in {metrics.stateName},
with listings and availability continuously updated throughout the day.
</p>
</div>
</div>
{/* CTA */}
<div className="max-w-4xl mx-auto px-4 py-12">
<div className="bg-emerald-50 rounded-xl p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Get {metrics.stateName} Market Intelligence
</h2>
<p className="text-gray-600 mb-6">
Access real-time pricing, brand distribution, and competitive insights for the {metrics.stateName} market.
</p>
<a
href={`/demo?state=${metrics.stateCode.toLowerCase()}`}
className="inline-block px-6 py-3 bg-emerald-600 text-white font-medium rounded-lg hover:bg-emerald-700 transition-colors"
>
Request {metrics.stateName} Demo
</a>
</div>
</div>
</div>
);
}
export default StatePage;

View File

@@ -0,0 +1,57 @@
/**
* Frontend Content Sanitizer - Safety hook for SEO content
*/
const FORBIDDEN_PATTERNS = [
/\b(crawl|crawled|crawler|crawling)\b/gi,
/\b(scrape|scraped|scraper|scraping)\b/gi,
/\bsnapshots?\b/gi,
/\b(ingestion|pipeline|ETL)\b/gi,
/\bworkers?\b/gi,
/\bbots?\b/gi,
];
const REPLACEMENTS: Array<{ pattern: RegExp; replacement: string }> = [
{ pattern: /\b(crawl|scrape)s?\b/gi, replacement: 'refresh' },
{ pattern: /\b(crawled|scraped)\b/gi, replacement: 'updated' },
{ pattern: /\b(crawler|scraper)s?\b/gi, replacement: 'data service' },
{ pattern: /\bsnapshots?\b/gi, replacement: 'market data' },
{ pattern: /\bworkers?\b/gi, replacement: 'services' },
];
export function hasForbiddenTerms(text: string): boolean {
if (typeof text !== 'string') return false;
return FORBIDDEN_PATTERNS.some(p => p.test(text));
}
export function sanitizeString(text: string): string {
if (typeof text !== 'string') return text;
let result = text;
for (const { pattern, replacement } of REPLACEMENTS) {
result = result.replace(pattern, replacement);
}
return result;
}
export function sanitizeContent<T>(content: T): T {
if (content === null || content === undefined) return content;
if (typeof content === 'string') return sanitizeString(content) as T;
if (Array.isArray(content)) return content.map(sanitizeContent) as T;
if (typeof content === 'object') {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(content)) {
result[key] = sanitizeContent(value);
}
return result as T;
}
return content;
}
export function useSanitizedSeoContent<T>(content: T): T {
const json = JSON.stringify(content);
if (hasForbiddenTerms(json)) {
console.warn('[ContentSanitizer] Forbidden terms detected');
return sanitizeContent(content);
}
return content;
}

View File

@@ -8,6 +8,7 @@
#
# This runs:
# - PostgreSQL database
# - Redis (job queues, caching, rate limiting)
# - Backend API server
# - Local filesystem storage (./storage)
#
@@ -31,6 +32,20 @@ services:
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: cannaiq-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
@@ -55,6 +70,8 @@ services:
JWT_SECRET: local_dev_jwt_secret_change_in_production
ADMIN_EMAIL: admin@example.com
ADMIN_PASSWORD: password
# Redis connection
REDIS_URL: redis://redis:6379
ports:
- "3010:3000"
volumes:
@@ -64,7 +81,10 @@ services:
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: npm run dev
volumes:
postgres_data:
redis_data:

View File

@@ -40,6 +40,24 @@ spec:
volumeMounts:
- name: images-storage
mountPath: /app/public/images
# Liveness probe: restarts pod if it becomes unresponsive
livenessProbe:
httpGet:
path: /health
port: 3010
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 10
failureThreshold: 3
# Readiness probe: removes pod from service if not ready
readinessProbe:
httpGet:
path: /health
port: 3010
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
memory: "512Mi"