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:
53
CLAUDE.md
53
CLAUDE.md
@@ -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)
|
||||
|
||||
108
backend/migrations/060_consumer_verification_notifications.sql
Normal file
108
backend/migrations/060_consumer_verification_notifications.sql
Normal 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);
|
||||
50
backend/migrations/061_product_click_events.sql
Normal file
50
backend/migrations/061_product_click_events.sql
Normal 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';
|
||||
41
backend/migrations/062_click_analytics_enhancements.sql
Normal file
41
backend/migrations/062_click_analytics_enhancements.sql
Normal 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';
|
||||
22
backend/migrations/063_seo_pages.sql
Normal file
22
backend/migrations/063_seo_pages.sql
Normal 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.';
|
||||
221
backend/node_modules/.package-lock.json
generated
vendored
221
backend/node_modules/.package-lock.json
generated
vendored
@@ -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",
|
||||
|
||||
285
backend/package-lock.json
generated
285
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -148,3 +148,64 @@ export function requireRole(...roles: string[]) {
|
||||
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
67
backend/src/cli.ts
Normal 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);
|
||||
});
|
||||
@@ -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,26 +204,25 @@ 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(
|
||||
// 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`,
|
||||
[parseInt(id, 10)]
|
||||
);
|
||||
[dispensaryId]
|
||||
),
|
||||
|
||||
if (dispensaryRows.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(
|
||||
// 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,
|
||||
@@ -231,73 +231,86 @@ router.get('/stores/:id/summary', async (req: Request, res: Response) => {
|
||||
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
|
||||
),
|
||||
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
|
||||
ORDER BY type, subcategory
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Get brands with counts for this store
|
||||
const { rows: brands } = await query(
|
||||
`
|
||||
SELECT
|
||||
brand_name,
|
||||
COUNT(*) as product_count
|
||||
) 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
|
||||
ORDER BY product_count DESC
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// Get last crawl info
|
||||
const { rows: lastCrawl } = await query(
|
||||
`
|
||||
) br
|
||||
),
|
||||
last_crawl AS (
|
||||
SELECT
|
||||
id,
|
||||
status,
|
||||
started_at,
|
||||
completed_at,
|
||||
products_found,
|
||||
products_new,
|
||||
products_updated,
|
||||
error_message
|
||||
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
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
[dispensaryId]
|
||||
)
|
||||
]);
|
||||
|
||||
const counts = countRows[0] || {};
|
||||
if (dispensaryResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Store not found' });
|
||||
}
|
||||
|
||||
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,12 +1722,14 @@ 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>(`
|
||||
// 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,
|
||||
@@ -1725,11 +1750,10 @@ router.get('/monitor/active-jobs', async (_req: Request, res: Response) => {
|
||||
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>(`
|
||||
// Query 2: Running crawl jobs with dispensary info
|
||||
query<any>(`
|
||||
SELECT
|
||||
cj.id,
|
||||
cj.job_type,
|
||||
@@ -1755,13 +1779,17 @@ router.get('/monitor/active-jobs', async (_req: Request, res: Response) => {
|
||||
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,39 +2518,57 @@ 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>(`
|
||||
// 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
|
||||
-- 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
|
||||
`);
|
||||
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>(`
|
||||
// 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>(`
|
||||
// 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,
|
||||
@@ -2531,22 +2577,16 @@ router.get('/scraper/overview', async (_req: Request, res: Response) => {
|
||||
FROM job_run_logs
|
||||
WHERE started_at > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY date_trunc('hour', started_at)
|
||||
ORDER BY hour ASC
|
||||
`);
|
||||
|
||||
// 4. Product growth / coverage (last 7 days)
|
||||
const { rows: growthRows } = await query<any>(`
|
||||
),
|
||||
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)
|
||||
ORDER BY day ASC
|
||||
`);
|
||||
|
||||
// 5. Recent worker runs (last 20)
|
||||
const { rows: recentRuns } = await query<any>(`
|
||||
),
|
||||
recent_runs AS (
|
||||
SELECT
|
||||
jrl.id,
|
||||
jrl.job_name,
|
||||
@@ -2563,10 +2603,30 @@ router.get('/scraper/overview', async (_req: Request, res: Response) => {
|
||||
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
|
||||
`),
|
||||
|
||||
// 6. Recent visibility changes by store
|
||||
const { rows: visibilityChanges } = await query<any>(`
|
||||
// Query 4: Visibility changes by store
|
||||
query<any>(`
|
||||
SELECT
|
||||
d.id AS dispensary_id,
|
||||
d.name AS dispensary_name,
|
||||
@@ -2583,9 +2643,21 @@ router.get('/scraper/overview', async (_req: Request, res: Response) => {
|
||||
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 results
|
||||
const kpi = kpiResult.rows[0] || {};
|
||||
const workerRows = workerResult.rows;
|
||||
const visibilityChanges = visibilityResult.rows;
|
||||
|
||||
// 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: {
|
||||
|
||||
@@ -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
108
backend/src/lib/redis.ts
Normal 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 };
|
||||
27
backend/src/migrations/052_create_seo_pages.sql
Normal file
27
backend/src/migrations/052_create_seo_pages.sql
Normal 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;
|
||||
24
backend/src/migrations/053_create_seo_page_contents.sql
Normal file
24
backend/src/migrations/053_create_seo_page_contents.sql
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
521
backend/src/routes/click-analytics.ts
Normal file
521
backend/src/routes/click-analytics.ts
Normal 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;
|
||||
378
backend/src/routes/consumer-alerts.ts
Normal file
378
backend/src/routes/consumer-alerts.ts
Normal 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;
|
||||
531
backend/src/routes/consumer-auth.ts
Normal file
531
backend/src/routes/consumer-auth.ts
Normal 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;
|
||||
409
backend/src/routes/consumer-deals.ts
Normal file
409
backend/src/routes/consumer-deals.ts
Normal 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;
|
||||
369
backend/src/routes/consumer-favorites.ts
Normal file
369
backend/src/routes/consumer-favorites.ts
Normal 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;
|
||||
389
backend/src/routes/consumer-saved-searches.ts
Normal file
389
backend/src/routes/consumer-saved-searches.ts
Normal 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;
|
||||
@@ -6,10 +6,12 @@ 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,
|
||||
@@ -18,36 +20,43 @@ router.get('/stats', async (req, res) => {
|
||||
MIN(last_crawled_at) as oldest_crawl,
|
||||
MAX(last_crawled_at) as latest_crawl
|
||||
FROM dispensaries
|
||||
`);
|
||||
|
||||
// Product stats from dutchie_products table
|
||||
const productsResult = await azQuery(`
|
||||
),
|
||||
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(DISTINCT dispensary_id) as dispensaries_with_products,
|
||||
COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') as new_products_24h
|
||||
FROM dutchie_products
|
||||
)
|
||||
SELECT
|
||||
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
|
||||
`);
|
||||
|
||||
// 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);
|
||||
|
||||
209
backend/src/routes/events.ts
Normal file
209
backend/src/routes/events.ts
Normal 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;
|
||||
455
backend/src/routes/health.ts
Normal file
455
backend/src/routes/health.ts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
238
backend/src/routes/seo.ts
Normal 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;
|
||||
622
backend/src/routes/workers.ts
Normal file
622
backend/src/routes/workers.ts
Normal 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;
|
||||
200
backend/src/services/seoGenerator.ts
Normal file
200
backend/src/services/seoGenerator.ts
Normal 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
|
||||
};
|
||||
}
|
||||
179
backend/src/utils/ContentValidator.ts
Normal file
179
backend/src/utils/ContentValidator.ts
Normal 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;
|
||||
115
backend/src/utils/HomepageValidator.ts
Normal file
115
backend/src/utils/HomepageValidator.ts
Normal 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,
|
||||
};
|
||||
124
backend/src/utils/__tests__/ContentValidator.test.ts
Normal file
124
backend/src/utils/__tests__/ContentValidator.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
4
cannaiq/dist/index.html
vendored
4
cannaiq/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
484
cannaiq/src/components/HealthPanel.tsx
Normal file
484
cannaiq/src/components/HealthPanel.tsx
Normal 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;
|
||||
@@ -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,18 +104,13 @@ export function Layout({ children }: LayoutProps) {
|
||||
return location.pathname.startsWith(path);
|
||||
};
|
||||
|
||||
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'
|
||||
}}
|
||||
>
|
||||
// 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">
|
||||
@@ -134,9 +121,8 @@ export function Layout({ children }: LayoutProps) {
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-gray-900">CannaIQ</span>
|
||||
{/* EMERALD_THEME_v2.0 */}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">{user?.email}</p>
|
||||
<p className="text-xs text-gray-500 mt-2 truncate">{user?.email}</p>
|
||||
</div>
|
||||
|
||||
{/* State Selector */}
|
||||
@@ -145,185 +131,32 @@ export function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-6">
|
||||
<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="/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')}
|
||||
/>
|
||||
<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="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 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="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 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"
|
||||
>
|
||||
<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>
|
||||
@@ -332,21 +165,60 @@ export function Layout({ children }: LayoutProps) {
|
||||
{/* 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>
|
||||
<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">
|
||||
{/* 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 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-8 py-8">
|
||||
<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="font-semibold text-gray-900">CannaIQ</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
187
cannaiq/src/content/alternatives/bdsa.ts
Normal file
187
cannaiq/src/content/alternatives/bdsa.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
157
cannaiq/src/content/alternatives/headset.ts
Normal file
157
cannaiq/src/content/alternatives/headset.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
157
cannaiq/src/content/alternatives/hoodie-analytics.ts
Normal file
157
cannaiq/src/content/alternatives/hoodie-analytics.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
33
cannaiq/src/content/alternatives/index.ts
Normal file
33
cannaiq/src/content/alternatives/index.ts
Normal 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);
|
||||
201
cannaiq/src/content/homepage.ts
Normal file
201
cannaiq/src/content/homepage.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
20
cannaiq/src/content/index.ts
Normal file
20
cannaiq/src/content/index.ts
Normal 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';
|
||||
185
cannaiq/src/content/landing/cannabis-brand-tracking.ts
Normal file
185
cannaiq/src/content/landing/cannabis-brand-tracking.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
239
cannaiq/src/content/landing/cannabis-data-platform.ts
Normal file
239
cannaiq/src/content/landing/cannabis-data-platform.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
175
cannaiq/src/content/landing/dispensary-pricing-software.ts
Normal file
175
cannaiq/src/content/landing/dispensary-pricing-software.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
37
cannaiq/src/content/landing/index.ts
Normal file
37
cannaiq/src/content/landing/index.ts
Normal 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);
|
||||
248
cannaiq/src/content/states/arizona.ts
Normal file
248
cannaiq/src/content/states/arizona.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
29
cannaiq/src/content/states/index.ts
Normal file
29
cannaiq/src/content/states/index.ts
Normal 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);
|
||||
239
cannaiq/src/content/types.ts
Normal file
239
cannaiq/src/content/types.ts
Normal 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;
|
||||
}
|
||||
78
cannaiq/src/hooks/useStateFilter.ts
Normal file
78
cannaiq/src/hooks/useStateFilter.ts
Normal 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;
|
||||
163
cannaiq/src/lib/analytics.ts
Normal file
163
cannaiq/src/lib/analytics.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
647
cannaiq/src/pages/ClickAnalytics.tsx
Normal file
647
cannaiq/src/pages/ClickAnalytics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' }}>
|
||||
@@ -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
525
cannaiq/src/pages/Home.tsx
Normal 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 market—powered 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">
|
||||
© {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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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 markets—built 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
>
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
214
cannaiq/src/pages/admin/seo/PagesTab.tsx
Normal file
214
cannaiq/src/pages/admin/seo/PagesTab.tsx
Normal 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;
|
||||
111
cannaiq/src/pages/admin/seo/SeoOrchestrator.tsx
Normal file
111
cannaiq/src/pages/admin/seo/SeoOrchestrator.tsx
Normal 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;
|
||||
310
cannaiq/src/pages/public/SeoPage.tsx
Normal file
310
cannaiq/src/pages/public/SeoPage.tsx
Normal 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;
|
||||
140
cannaiq/src/pages/public/StatePage.tsx
Normal file
140
cannaiq/src/pages/public/StatePage.tsx
Normal 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;
|
||||
57
cannaiq/src/utils/contentSanitizer.ts
Normal file
57
cannaiq/src/utils/contentSanitizer.ts
Normal 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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user