Compare commits
33 Commits
feat/auto-
...
fix/analyt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e22b439e0 | ||
|
|
1fb0eb94c2 | ||
|
|
9aefb554bc | ||
|
|
a4338669a9 | ||
|
|
1fa9ea496c | ||
|
|
31756a2233 | ||
|
|
166583621b | ||
|
|
ca952c4674 | ||
|
|
4054778b6c | ||
|
|
56a5f00015 | ||
|
|
a96d50c481 | ||
|
|
4806212f46 | ||
|
|
2486f3c6b2 | ||
|
|
f25bebf6ee | ||
|
|
22dad6d0fc | ||
|
|
03eab66d35 | ||
|
|
97b1ab23d8 | ||
|
|
9fff0ba430 | ||
|
|
7d3e91b2e6 | ||
|
|
74957a9ec5 | ||
|
|
2d035c46cf | ||
|
|
53445fe72a | ||
|
|
37cc8956c5 | ||
|
|
197c82f921 | ||
|
|
2c52493a9c | ||
|
|
2ee2ba6b8c | ||
|
|
bafcf1694a | ||
|
|
95792aab15 | ||
|
|
38ae2c3a3e | ||
|
|
249d3c1b7f | ||
|
|
9647f94f89 | ||
|
|
afc288d2cf | ||
|
|
df01ce6aad |
@@ -45,6 +45,31 @@ steps:
|
|||||||
when:
|
when:
|
||||||
event: pull_request
|
event: pull_request
|
||||||
|
|
||||||
|
# ===========================================
|
||||||
|
# AUTO-MERGE: Merge PR after all checks pass
|
||||||
|
# ===========================================
|
||||||
|
auto-merge:
|
||||||
|
image: alpine:latest
|
||||||
|
environment:
|
||||||
|
GITEA_TOKEN:
|
||||||
|
from_secret: gitea_token
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache curl
|
||||||
|
- |
|
||||||
|
echo "Merging PR #${CI_COMMIT_PULL_REQUEST}..."
|
||||||
|
curl -s -X POST \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"Do":"merge"}' \
|
||||||
|
"https://code.cannabrands.app/api/v1/repos/Creationshop/dispensary-scraper/pulls/${CI_COMMIT_PULL_REQUEST}/merge"
|
||||||
|
depends_on:
|
||||||
|
- typecheck-backend
|
||||||
|
- typecheck-cannaiq
|
||||||
|
- typecheck-findadispo
|
||||||
|
- typecheck-findagram
|
||||||
|
when:
|
||||||
|
event: pull_request
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# MASTER DEPLOY: Parallel Docker builds
|
# MASTER DEPLOY: Parallel Docker builds
|
||||||
# ===========================================
|
# ===========================================
|
||||||
@@ -65,10 +90,10 @@ steps:
|
|||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
provenance: false
|
provenance: false
|
||||||
build_args:
|
build_args:
|
||||||
- APP_BUILD_VERSION=${CI_COMMIT_SHA}
|
APP_BUILD_VERSION: ${CI_COMMIT_SHA:0:8}
|
||||||
- APP_GIT_SHA=${CI_COMMIT_SHA}
|
APP_GIT_SHA: ${CI_COMMIT_SHA}
|
||||||
- APP_BUILD_TIME=${CI_PIPELINE_CREATED}
|
APP_BUILD_TIME: ${CI_PIPELINE_CREATED}
|
||||||
- CONTAINER_IMAGE_TAG=${CI_COMMIT_SHA:0:8}
|
CONTAINER_IMAGE_TAG: ${CI_COMMIT_SHA:0:8}
|
||||||
depends_on: []
|
depends_on: []
|
||||||
when:
|
when:
|
||||||
branch: master
|
branch: master
|
||||||
|
|||||||
38
CLAUDE.md
38
CLAUDE.md
@@ -119,7 +119,42 @@ npx tsx src/db/migrate.ts
|
|||||||
- Importing it at runtime causes startup crashes if env vars aren't perfect
|
- Importing it at runtime causes startup crashes if env vars aren't perfect
|
||||||
- `pool.ts` uses lazy initialization - only validates when first query is made
|
- `pool.ts` uses lazy initialization - only validates when first query is made
|
||||||
|
|
||||||
### 6. LOCAL DEVELOPMENT BY DEFAULT
|
### 6. ALL API ROUTES REQUIRE AUTHENTICATION — NO EXCEPTIONS
|
||||||
|
|
||||||
|
**Every API router MUST apply `authMiddleware` at the router level.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { authMiddleware } from '../auth/middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(authMiddleware); // REQUIRED - first line after router creation
|
||||||
|
```
|
||||||
|
|
||||||
|
**Authentication flow (see `src/auth/middleware.ts`):**
|
||||||
|
1. Check Bearer token (JWT or API token) → grant access if valid
|
||||||
|
2. Check trusted origins (cannaiq.co, findadispo.com, localhost, etc.) → grant access
|
||||||
|
3. Check trusted IPs (127.0.0.1, ::1, internal pod IPs) → grant access
|
||||||
|
4. **Return 401 Unauthorized** if none of the above
|
||||||
|
|
||||||
|
**NEVER create API routes without auth middleware:**
|
||||||
|
- No "public" endpoints that bypass authentication
|
||||||
|
- No "read-only" exceptions
|
||||||
|
- No "analytics-only" exceptions
|
||||||
|
- If an endpoint exists under `/api/*`, it MUST be protected
|
||||||
|
|
||||||
|
**When creating new route files:**
|
||||||
|
1. Import `authMiddleware` from `../auth/middleware`
|
||||||
|
2. Add `router.use(authMiddleware)` immediately after creating the router
|
||||||
|
3. Document security requirements in file header comments
|
||||||
|
|
||||||
|
**Trusted origins (defined in middleware):**
|
||||||
|
- `https://cannaiq.co`
|
||||||
|
- `https://findadispo.com`
|
||||||
|
- `https://findagram.co`
|
||||||
|
- `*.cannabrands.app` domains
|
||||||
|
- `localhost:*` for development
|
||||||
|
|
||||||
|
### 7. LOCAL DEVELOPMENT BY DEFAULT
|
||||||
|
|
||||||
**Quick Start:**
|
**Quick Start:**
|
||||||
```bash
|
```bash
|
||||||
@@ -452,6 +487,7 @@ const result = await pool.query(`
|
|||||||
16. **Running `lsof -ti:PORT | xargs kill`** or similar process-killing commands
|
16. **Running `lsof -ti:PORT | xargs kill`** or similar process-killing commands
|
||||||
17. **Using hardcoded database names** in code or comments
|
17. **Using hardcoded database names** in code or comments
|
||||||
18. **Creating or connecting to a second database**
|
18. **Creating or connecting to a second database**
|
||||||
|
19. **Creating API routes without authMiddleware** (all `/api/*` routes MUST be protected)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ FROM code.cannabrands.app/creationshop/node:20-slim AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -43,10 +43,13 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy migrations for auto-migrate on startup
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
# Create local images directory for when MinIO is not configured
|
# Create local images directory for when MinIO is not configured
|
||||||
RUN mkdir -p /app/public/images/products
|
RUN mkdir -p /app/public/images/products
|
||||||
|
|
||||||
|
|||||||
394
backend/docs/BRAND_INTELLIGENCE_API.md
Normal file
394
backend/docs/BRAND_INTELLIGENCE_API.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# Brand Intelligence API
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/analytics/v2/brand/:name/intelligence
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
| Param | Type | Default | Description |
|
||||||
|
|-------|------|---------|-------------|
|
||||||
|
| `window` | `7d\|30d\|90d` | `30d` | Time window for trend calculations |
|
||||||
|
| `state` | string | - | Filter by state code (e.g., `AZ`) |
|
||||||
|
| `category` | string | - | Filter by category (e.g., `Flower`) |
|
||||||
|
|
||||||
|
## Response Payload Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BrandIntelligenceResult {
|
||||||
|
brand_name: string;
|
||||||
|
window: '7d' | '30d' | '90d';
|
||||||
|
generated_at: string; // ISO timestamp when data was computed
|
||||||
|
|
||||||
|
performance_snapshot: PerformanceSnapshot;
|
||||||
|
alerts: Alerts;
|
||||||
|
sku_performance: SkuPerformance[];
|
||||||
|
retail_footprint: RetailFootprint;
|
||||||
|
competitive_landscape: CompetitiveLandscape;
|
||||||
|
inventory_health: InventoryHealth;
|
||||||
|
promo_performance: PromoPerformance;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 1: Performance Snapshot
|
||||||
|
|
||||||
|
Summary cards with key brand metrics.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PerformanceSnapshot {
|
||||||
|
active_skus: number; // Total products in catalog
|
||||||
|
total_revenue_30d: number | null; // Estimated from qty × price
|
||||||
|
total_stores: number; // Active retail partners
|
||||||
|
new_stores_30d: number; // New distribution in window
|
||||||
|
market_share: number | null; // % of category SKUs
|
||||||
|
avg_wholesale_price: number | null;
|
||||||
|
price_position: 'premium' | 'value' | 'competitive';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Label Mapping:**
|
||||||
|
| Field | User-Facing Label | Helper Text |
|
||||||
|
|-------|-------------------|-------------|
|
||||||
|
| `active_skus` | Active Products | X total in catalog |
|
||||||
|
| `total_revenue_30d` | Monthly Revenue | Estimated from sales |
|
||||||
|
| `total_stores` | Retail Distribution | Active retail partners |
|
||||||
|
| `new_stores_30d` | New Opportunities | X new in last 30 days |
|
||||||
|
| `market_share` | Category Position | % of category |
|
||||||
|
| `avg_wholesale_price` | Avg Wholesale | Per unit |
|
||||||
|
| `price_position` | Pricing Tier | Premium/Value/Market Rate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 2: Alerts
|
||||||
|
|
||||||
|
Issues requiring attention.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Alerts {
|
||||||
|
lost_stores_30d_count: number;
|
||||||
|
lost_skus_30d_count: number;
|
||||||
|
competitor_takeover_count: number;
|
||||||
|
avg_oos_duration_days: number | null;
|
||||||
|
avg_reorder_lag_days: number | null;
|
||||||
|
items: AlertItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertItem {
|
||||||
|
type: 'lost_store' | 'delisted_sku' | 'shelf_loss' | 'extended_oos';
|
||||||
|
severity: 'critical' | 'warning';
|
||||||
|
store_name?: string;
|
||||||
|
product_name?: string;
|
||||||
|
competitor_brand?: string;
|
||||||
|
days_since?: number;
|
||||||
|
state_code?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Label Mapping:**
|
||||||
|
| Field | User-Facing Label |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `lost_stores_30d_count` | Accounts at Risk |
|
||||||
|
| `lost_skus_30d_count` | Delisted SKUs |
|
||||||
|
| `competitor_takeover_count` | Shelf Losses |
|
||||||
|
| `avg_oos_duration_days` | Avg Stockout Length |
|
||||||
|
| `avg_reorder_lag_days` | Avg Restock Time |
|
||||||
|
| `severity: critical` | Urgent |
|
||||||
|
| `severity: warning` | Watch |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 3: SKU Performance (Product Velocity)
|
||||||
|
|
||||||
|
How fast each SKU sells.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SkuPerformance {
|
||||||
|
store_product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
category: string | null;
|
||||||
|
daily_velocity: number; // Units/day estimate
|
||||||
|
velocity_status: 'hot' | 'steady' | 'slow' | 'stale';
|
||||||
|
retail_price: number | null;
|
||||||
|
on_sale: boolean;
|
||||||
|
stores_carrying: number;
|
||||||
|
stock_status: 'in_stock' | 'low_stock' | 'out_of_stock';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Label Mapping:**
|
||||||
|
| Field | User-Facing Label |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `daily_velocity` | Daily Rate |
|
||||||
|
| `velocity_status` | Momentum |
|
||||||
|
| `velocity_status: hot` | Hot |
|
||||||
|
| `velocity_status: steady` | Steady |
|
||||||
|
| `velocity_status: slow` | Slow |
|
||||||
|
| `velocity_status: stale` | Stale |
|
||||||
|
| `retail_price` | Retail Price |
|
||||||
|
| `on_sale` | Promo (badge) |
|
||||||
|
|
||||||
|
**Velocity Thresholds:**
|
||||||
|
- `hot`: >= 5 units/day
|
||||||
|
- `steady`: >= 1 unit/day
|
||||||
|
- `slow`: >= 0.1 units/day
|
||||||
|
- `stale`: < 0.1 units/day
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 4: Retail Footprint
|
||||||
|
|
||||||
|
Store placement and coverage.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RetailFootprint {
|
||||||
|
total_stores: number;
|
||||||
|
in_stock_count: number;
|
||||||
|
out_of_stock_count: number;
|
||||||
|
penetration_by_region: RegionPenetration[];
|
||||||
|
whitespace_stores: WhitespaceStore[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegionPenetration {
|
||||||
|
state_code: string;
|
||||||
|
store_count: number;
|
||||||
|
percent_reached: number; // % of state's dispensaries
|
||||||
|
in_stock: number;
|
||||||
|
out_of_stock: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhitespaceStore {
|
||||||
|
store_id: number;
|
||||||
|
store_name: string;
|
||||||
|
state_code: string;
|
||||||
|
city: string | null;
|
||||||
|
category_fit: number; // How many competing brands they carry
|
||||||
|
competitor_brands: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Label Mapping:**
|
||||||
|
| Field | User-Facing Label |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `penetration_by_region` | Market Coverage by Region |
|
||||||
|
| `percent_reached` | X% reached |
|
||||||
|
| `in_stock` | X stocked |
|
||||||
|
| `out_of_stock` | X out |
|
||||||
|
| `whitespace_stores` | Expansion Opportunities |
|
||||||
|
| `category_fit` | X fit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 5: Competitive Landscape
|
||||||
|
|
||||||
|
Market positioning vs competitors.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CompetitiveLandscape {
|
||||||
|
brand_price_position: 'premium' | 'value' | 'competitive';
|
||||||
|
market_share_trend: MarketSharePoint[];
|
||||||
|
competitors: Competitor[];
|
||||||
|
head_to_head_skus: HeadToHead[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarketSharePoint {
|
||||||
|
date: string;
|
||||||
|
share_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Competitor {
|
||||||
|
brand_name: string;
|
||||||
|
store_overlap_percent: number;
|
||||||
|
price_position: 'premium' | 'value' | 'competitive';
|
||||||
|
avg_price: number | null;
|
||||||
|
sku_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeadToHead {
|
||||||
|
product_name: string;
|
||||||
|
brand_price: number;
|
||||||
|
competitor_brand: string;
|
||||||
|
competitor_price: number;
|
||||||
|
price_diff_percent: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Label Mapping:**
|
||||||
|
| Field | User-Facing Label |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `price_position: premium` | Premium Tier |
|
||||||
|
| `price_position: value` | Value Leader |
|
||||||
|
| `price_position: competitive` | Market Rate |
|
||||||
|
| `market_share_trend` | Share of Shelf Trend |
|
||||||
|
| `head_to_head_skus` | Price Comparison |
|
||||||
|
| `store_overlap_percent` | X% store overlap |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 6: Inventory Health
|
||||||
|
|
||||||
|
Stock projections and risk levels.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InventoryHealth {
|
||||||
|
critical_count: number; // <7 days stock
|
||||||
|
warning_count: number; // 7-14 days stock
|
||||||
|
healthy_count: number; // 14-90 days stock
|
||||||
|
overstocked_count: number; // >90 days stock
|
||||||
|
skus: InventorySku[];
|
||||||
|
overstock_alert: OverstockItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InventorySku {
|
||||||
|
store_product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
store_name: string;
|
||||||
|
days_of_stock: number | null;
|
||||||
|
risk_level: 'critical' | 'elevated' | 'moderate' | 'healthy';
|
||||||
|
current_quantity: number | null;
|
||||||
|
daily_sell_rate: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OverstockItem {
|
||||||
|
product_name: string;
|
||||||
|
store_name: string;
|
||||||
|
excess_units: number;
|
||||||
|
days_of_stock: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Label Mapping:**
|
||||||
|
| Field | User-Facing Label |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `risk_level: critical` | Reorder Now |
|
||||||
|
| `risk_level: elevated` | Low Stock |
|
||||||
|
| `risk_level: moderate` | Monitor |
|
||||||
|
| `risk_level: healthy` | Healthy |
|
||||||
|
| `critical_count` | Urgent (<7 days) |
|
||||||
|
| `warning_count` | Low (7-14 days) |
|
||||||
|
| `overstocked_count` | Excess (>90 days) |
|
||||||
|
| `days_of_stock` | X days remaining |
|
||||||
|
| `overstock_alert` | Overstock Alert |
|
||||||
|
| `excess_units` | X excess units |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Section 7: Promotion Effectiveness
|
||||||
|
|
||||||
|
How promotions impact sales.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface PromoPerformance {
|
||||||
|
avg_baseline_velocity: number | null;
|
||||||
|
avg_promo_velocity: number | null;
|
||||||
|
avg_velocity_lift: number | null; // % increase during promo
|
||||||
|
avg_efficiency_score: number | null; // ROI proxy
|
||||||
|
promotions: Promotion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Promotion {
|
||||||
|
product_name: string;
|
||||||
|
store_name: string;
|
||||||
|
status: 'active' | 'scheduled' | 'ended';
|
||||||
|
start_date: string;
|
||||||
|
end_date: string | null;
|
||||||
|
regular_price: number;
|
||||||
|
promo_price: number;
|
||||||
|
discount_percent: number;
|
||||||
|
baseline_velocity: number | null;
|
||||||
|
promo_velocity: number | null;
|
||||||
|
velocity_lift: number | null;
|
||||||
|
efficiency_score: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Label Mapping:**
|
||||||
|
| Field | User-Facing Label |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `avg_baseline_velocity` | Normal Rate |
|
||||||
|
| `avg_promo_velocity` | During Promos |
|
||||||
|
| `avg_velocity_lift` | Avg Sales Lift |
|
||||||
|
| `avg_efficiency_score` | ROI Score |
|
||||||
|
| `velocity_lift` | Sales Lift |
|
||||||
|
| `efficiency_score` | ROI Score |
|
||||||
|
| `status: active` | Live |
|
||||||
|
| `status: scheduled` | Scheduled |
|
||||||
|
| `status: ended` | Ended |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Queries
|
||||||
|
|
||||||
|
### Get full payload
|
||||||
|
```javascript
|
||||||
|
const response = await fetch('/api/analytics/v2/brand/Wyld/intelligence?window=30d');
|
||||||
|
const data = await response.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extract summary cards (flattened)
|
||||||
|
```javascript
|
||||||
|
const { performance_snapshot: ps, alerts } = data;
|
||||||
|
|
||||||
|
const summaryCards = {
|
||||||
|
activeProducts: ps.active_skus,
|
||||||
|
monthlyRevenue: ps.total_revenue_30d,
|
||||||
|
retailDistribution: ps.total_stores,
|
||||||
|
newOpportunities: ps.new_stores_30d,
|
||||||
|
categoryPosition: ps.market_share,
|
||||||
|
avgWholesale: ps.avg_wholesale_price,
|
||||||
|
pricingTier: ps.price_position,
|
||||||
|
accountsAtRisk: alerts.lost_stores_30d_count,
|
||||||
|
delistedSkus: alerts.lost_skus_30d_count,
|
||||||
|
shelfLosses: alerts.competitor_takeover_count,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get top 10 fastest selling SKUs
|
||||||
|
```javascript
|
||||||
|
const topSkus = data.sku_performance
|
||||||
|
.filter(sku => sku.velocity_status === 'hot' || sku.velocity_status === 'steady')
|
||||||
|
.sort((a, b) => b.daily_velocity - a.daily_velocity)
|
||||||
|
.slice(0, 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get critical inventory alerts only
|
||||||
|
```javascript
|
||||||
|
const criticalInventory = data.inventory_health.skus
|
||||||
|
.filter(sku => sku.risk_level === 'critical');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get states with <50% penetration
|
||||||
|
```javascript
|
||||||
|
const underPenetrated = data.retail_footprint.penetration_by_region
|
||||||
|
.filter(region => region.percent_reached < 50)
|
||||||
|
.sort((a, b) => a.percent_reached - b.percent_reached);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get active promotions with positive lift
|
||||||
|
```javascript
|
||||||
|
const effectivePromos = data.promo_performance.promotions
|
||||||
|
.filter(p => p.status === 'active' && p.velocity_lift > 0)
|
||||||
|
.sort((a, b) => b.velocity_lift - a.velocity_lift);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build chart data for market share trend
|
||||||
|
```javascript
|
||||||
|
const chartData = data.competitive_landscape.market_share_trend.map(point => ({
|
||||||
|
x: new Date(point.date),
|
||||||
|
y: point.share_percent,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for Frontend Implementation
|
||||||
|
|
||||||
|
1. **All fields are snake_case** - transform to camelCase if needed
|
||||||
|
2. **Null values are possible** - handle gracefully in UI
|
||||||
|
3. **Arrays may be empty** - show appropriate empty states
|
||||||
|
4. **Timestamps are ISO format** - parse with `new Date()`
|
||||||
|
5. **Percentages are already computed** - no need to multiply by 100
|
||||||
|
6. **The `window` parameter affects trend calculations** - 7d/30d/90d
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dutchie-menus-backend",
|
"name": "dutchie-menus-backend",
|
||||||
"version": "1.5.1",
|
"version": "1.6.0",
|
||||||
"description": "Backend API for Dutchie Menus scraper and management",
|
"description": "Backend API for Dutchie Menus scraper and management",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
BIN
backend/public/downloads/cannaiq-menus-1.6.0.zip
Normal file
BIN
backend/public/downloads/cannaiq-menus-1.6.0.zip
Normal file
Binary file not shown.
1
backend/public/downloads/cannaiq-menus-latest.zip
Symbolic link
1
backend/public/downloads/cannaiq-menus-latest.zip
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
cannaiq-menus-1.6.0.zip
|
||||||
@@ -32,6 +32,7 @@ const TRUSTED_ORIGINS = [
|
|||||||
// Pattern-based trusted origins (wildcards)
|
// Pattern-based trusted origins (wildcards)
|
||||||
const TRUSTED_ORIGIN_PATTERNS = [
|
const TRUSTED_ORIGIN_PATTERNS = [
|
||||||
/^https:\/\/.*\.cannabrands\.app$/, // *.cannabrands.app
|
/^https:\/\/.*\.cannabrands\.app$/, // *.cannabrands.app
|
||||||
|
/^https:\/\/.*\.cannaiq\.co$/, // *.cannaiq.co
|
||||||
];
|
];
|
||||||
|
|
||||||
// Trusted IPs for internal pod-to-pod communication
|
// Trusted IPs for internal pod-to-pod communication
|
||||||
@@ -152,22 +153,10 @@ export async function authenticateUser(email: string, password: string): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
export async function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
// Allow trusted origins/IPs to bypass auth (internal services, same-origin)
|
|
||||||
if (isTrustedRequest(req)) {
|
|
||||||
req.user = {
|
|
||||||
id: 0,
|
|
||||||
email: 'internal@system',
|
|
||||||
role: 'internal'
|
|
||||||
};
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
// If a Bearer token is provided, always try to use it first (logged-in user)
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
// Try JWT first
|
// Try JWT first
|
||||||
@@ -186,56 +175,44 @@ export async function authMiddleware(req: AuthRequest, res: Response, next: Next
|
|||||||
WHERE token = $1
|
WHERE token = $1
|
||||||
`, [token]);
|
`, [token]);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length > 0) {
|
||||||
|
const apiToken = result.rows[0];
|
||||||
|
if (!apiToken.active) {
|
||||||
|
return res.status(401).json({ error: 'API token is inactive' });
|
||||||
|
}
|
||||||
|
if (apiToken.expires_at && new Date(apiToken.expires_at) < new Date()) {
|
||||||
|
return res.status(401).json({ error: 'API token has expired' });
|
||||||
|
}
|
||||||
|
req.user = {
|
||||||
|
id: 0,
|
||||||
|
email: `api:${apiToken.name}`,
|
||||||
|
role: 'api_token'
|
||||||
|
};
|
||||||
|
req.apiToken = apiToken;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('API token lookup error:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token provided but invalid
|
||||||
return res.status(401).json({ error: 'Invalid token' });
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiToken = result.rows[0];
|
// No token provided - check trusted origins for API access (WordPress, etc.)
|
||||||
|
if (isTrustedRequest(req)) {
|
||||||
// Check if token is active
|
|
||||||
if (!apiToken.active) {
|
|
||||||
return res.status(401).json({ error: 'Token is disabled' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if token is expired
|
|
||||||
if (apiToken.expires_at && new Date(apiToken.expires_at) < new Date()) {
|
|
||||||
return res.status(401).json({ error: 'Token has expired' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check allowed endpoints
|
|
||||||
if (apiToken.allowed_endpoints && apiToken.allowed_endpoints.length > 0) {
|
|
||||||
const isAllowed = apiToken.allowed_endpoints.some((pattern: string) => {
|
|
||||||
// Simple wildcard matching
|
|
||||||
const regex = new RegExp('^' + pattern.replace('*', '.*') + '$');
|
|
||||||
return regex.test(req.path);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isAllowed) {
|
|
||||||
return res.status(403).json({ error: 'Endpoint not allowed for this token' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set API token on request for tracking
|
|
||||||
req.apiToken = {
|
|
||||||
id: apiToken.id,
|
|
||||||
name: apiToken.name,
|
|
||||||
rate_limit: apiToken.rate_limit
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set a generic user for compatibility with existing code
|
|
||||||
req.user = {
|
req.user = {
|
||||||
id: apiToken.id,
|
id: 0,
|
||||||
email: `api-token-${apiToken.id}@system`,
|
email: 'internal@system',
|
||||||
role: 'api'
|
role: 'internal'
|
||||||
};
|
};
|
||||||
|
return next();
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying API token:', error);
|
|
||||||
return res.status(500).json({ error: 'Authentication failed' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Require specific role(s) to access endpoint.
|
* Require specific role(s) to access endpoint.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export async function upsertStoreProducts(
|
|||||||
name_raw, brand_name_raw, category_raw, subcategory_raw,
|
name_raw, brand_name_raw, category_raw, subcategory_raw,
|
||||||
price_rec, price_med, price_rec_special, price_med_special,
|
price_rec, price_med, price_rec_special, price_med_special,
|
||||||
is_on_special, discount_percent,
|
is_on_special, discount_percent,
|
||||||
is_in_stock, stock_status,
|
is_in_stock, stock_status, stock_quantity, total_quantity_available,
|
||||||
thc_percent, cbd_percent,
|
thc_percent, cbd_percent,
|
||||||
image_url,
|
image_url,
|
||||||
first_seen_at, last_seen_at, updated_at
|
first_seen_at, last_seen_at, updated_at
|
||||||
@@ -99,9 +99,9 @@ export async function upsertStoreProducts(
|
|||||||
$5, $6, $7, $8,
|
$5, $6, $7, $8,
|
||||||
$9, $10, $11, $12,
|
$9, $10, $11, $12,
|
||||||
$13, $14,
|
$13, $14,
|
||||||
$15, $16,
|
$15, $16, $17, $17,
|
||||||
$17, $18,
|
$18, $19,
|
||||||
$19,
|
$20,
|
||||||
NOW(), NOW(), NOW()
|
NOW(), NOW(), NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (dispensary_id, provider, provider_product_id)
|
ON CONFLICT (dispensary_id, provider, provider_product_id)
|
||||||
@@ -118,6 +118,8 @@ export async function upsertStoreProducts(
|
|||||||
discount_percent = EXCLUDED.discount_percent,
|
discount_percent = EXCLUDED.discount_percent,
|
||||||
is_in_stock = EXCLUDED.is_in_stock,
|
is_in_stock = EXCLUDED.is_in_stock,
|
||||||
stock_status = EXCLUDED.stock_status,
|
stock_status = EXCLUDED.stock_status,
|
||||||
|
stock_quantity = EXCLUDED.stock_quantity,
|
||||||
|
total_quantity_available = EXCLUDED.total_quantity_available,
|
||||||
thc_percent = EXCLUDED.thc_percent,
|
thc_percent = EXCLUDED.thc_percent,
|
||||||
cbd_percent = EXCLUDED.cbd_percent,
|
cbd_percent = EXCLUDED.cbd_percent,
|
||||||
image_url = EXCLUDED.image_url,
|
image_url = EXCLUDED.image_url,
|
||||||
@@ -141,6 +143,7 @@ export async function upsertStoreProducts(
|
|||||||
productPricing?.discountPercent,
|
productPricing?.discountPercent,
|
||||||
productAvailability?.inStock ?? true,
|
productAvailability?.inStock ?? true,
|
||||||
productAvailability?.stockStatus || 'unknown',
|
productAvailability?.stockStatus || 'unknown',
|
||||||
|
productAvailability?.quantity ?? null, // stock_quantity and total_quantity_available
|
||||||
// Clamp THC/CBD to valid percentage range (0-100) - some products report mg as %
|
// Clamp THC/CBD to valid percentage range (0-100) - some products report mg as %
|
||||||
product.thcPercent !== null && product.thcPercent <= 100 ? product.thcPercent : null,
|
product.thcPercent !== null && product.thcPercent <= 100 ? product.thcPercent : null,
|
||||||
product.cbdPercent !== null && product.cbdPercent <= 100 ? product.cbdPercent : null,
|
product.cbdPercent !== null && product.cbdPercent <= 100 ? product.cbdPercent : null,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Request, Response, NextFunction } from 'express';
|
|||||||
* These are our own frontends that should have unrestricted access.
|
* These are our own frontends that should have unrestricted access.
|
||||||
*/
|
*/
|
||||||
const TRUSTED_DOMAINS = [
|
const TRUSTED_DOMAINS = [
|
||||||
'cannaiq.co',
|
'*.cannaiq.co',
|
||||||
'www.cannaiq.co',
|
'*.cannabrands.app',
|
||||||
'findagram.co',
|
'findagram.co',
|
||||||
'www.findagram.co',
|
'www.findagram.co',
|
||||||
'findadispo.com',
|
'findadispo.com',
|
||||||
@@ -32,6 +32,24 @@ function extractDomain(header: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a domain matches any trusted domain (supports *.domain.com wildcards)
|
||||||
|
*/
|
||||||
|
function isTrustedDomain(domain: string): boolean {
|
||||||
|
for (const trusted of TRUSTED_DOMAINS) {
|
||||||
|
if (trusted.startsWith('*.')) {
|
||||||
|
// Wildcard: *.example.com matches example.com and any subdomain
|
||||||
|
const baseDomain = trusted.slice(2);
|
||||||
|
if (domain === baseDomain || domain.endsWith('.' + baseDomain)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (domain === trusted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if the request comes from a trusted domain
|
* Checks if the request comes from a trusted domain
|
||||||
*/
|
*/
|
||||||
@@ -42,7 +60,7 @@ function isRequestFromTrustedDomain(req: Request): boolean {
|
|||||||
// Check Origin header first (preferred for CORS requests)
|
// Check Origin header first (preferred for CORS requests)
|
||||||
if (origin) {
|
if (origin) {
|
||||||
const domain = extractDomain(origin);
|
const domain = extractDomain(origin);
|
||||||
if (domain && TRUSTED_DOMAINS.includes(domain)) {
|
if (domain && isTrustedDomain(domain)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +68,7 @@ function isRequestFromTrustedDomain(req: Request): boolean {
|
|||||||
// Fallback to Referer header
|
// Fallback to Referer header
|
||||||
if (referer) {
|
if (referer) {
|
||||||
const domain = extractDomain(referer);
|
const domain = extractDomain(referer);
|
||||||
if (domain && TRUSTED_DOMAINS.includes(domain)) {
|
if (domain && isTrustedDomain(domain)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,23 @@
|
|||||||
* Routes are prefixed with /api/analytics/v2
|
* Routes are prefixed with /api/analytics/v2
|
||||||
*
|
*
|
||||||
* Phase 3: Analytics Engine + Rec/Med by State
|
* Phase 3: Analytics Engine + Rec/Med by State
|
||||||
|
*
|
||||||
|
* SECURITY: All routes require authentication via authMiddleware.
|
||||||
|
* Access is granted to:
|
||||||
|
* - Trusted origins (cannaiq.co, findadispo.com, etc.)
|
||||||
|
* - Trusted IPs (localhost, internal pods)
|
||||||
|
* - Valid JWT or API tokens
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
import { authMiddleware } from '../auth/middleware';
|
||||||
import { PriceAnalyticsService } from '../services/analytics/PriceAnalyticsService';
|
import { PriceAnalyticsService } from '../services/analytics/PriceAnalyticsService';
|
||||||
import { BrandPenetrationService } from '../services/analytics/BrandPenetrationService';
|
import { BrandPenetrationService } from '../services/analytics/BrandPenetrationService';
|
||||||
import { CategoryAnalyticsService } from '../services/analytics/CategoryAnalyticsService';
|
import { CategoryAnalyticsService } from '../services/analytics/CategoryAnalyticsService';
|
||||||
import { StoreAnalyticsService } from '../services/analytics/StoreAnalyticsService';
|
import { StoreAnalyticsService } from '../services/analytics/StoreAnalyticsService';
|
||||||
import { StateAnalyticsService } from '../services/analytics/StateAnalyticsService';
|
import { StateAnalyticsService } from '../services/analytics/StateAnalyticsService';
|
||||||
|
import { BrandIntelligenceService } from '../services/analytics/BrandIntelligenceService';
|
||||||
import { TimeWindow, LegalType } from '../services/analytics/types';
|
import { TimeWindow, LegalType } from '../services/analytics/types';
|
||||||
|
|
||||||
function parseTimeWindow(window?: string): TimeWindow {
|
function parseTimeWindow(window?: string): TimeWindow {
|
||||||
@@ -35,12 +43,17 @@ function parseLegalType(legalType?: string): LegalType {
|
|||||||
export function createAnalyticsV2Router(pool: Pool): Router {
|
export function createAnalyticsV2Router(pool: Pool): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// SECURITY: Apply auth middleware to ALL routes
|
||||||
|
// This gate ensures only authenticated requests can access analytics data
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
const priceService = new PriceAnalyticsService(pool);
|
const priceService = new PriceAnalyticsService(pool);
|
||||||
const brandService = new BrandPenetrationService(pool);
|
const brandService = new BrandPenetrationService(pool);
|
||||||
const categoryService = new CategoryAnalyticsService(pool);
|
const categoryService = new CategoryAnalyticsService(pool);
|
||||||
const storeService = new StoreAnalyticsService(pool);
|
const storeService = new StoreAnalyticsService(pool);
|
||||||
const stateService = new StateAnalyticsService(pool);
|
const stateService = new StateAnalyticsService(pool);
|
||||||
|
const brandIntelligenceService = new BrandIntelligenceService(pool);
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// PRICE ANALYTICS
|
// PRICE ANALYTICS
|
||||||
@@ -231,6 +244,76 @@ export function createAnalyticsV2Router(pool: Pool): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /brand/:name/promotions
|
||||||
|
* Get brand promotional history - tracks specials, discounts, duration, and sales estimates
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - window: 7d|30d|90d (default: 90d)
|
||||||
|
* - state: state code filter (e.g., AZ)
|
||||||
|
* - category: category filter (e.g., Flower)
|
||||||
|
*/
|
||||||
|
router.get('/brand/:name/promotions', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const brandName = decodeURIComponent(req.params.name);
|
||||||
|
const window = parseTimeWindow(req.query.window as string) || '90d';
|
||||||
|
const stateCode = req.query.state as string | undefined;
|
||||||
|
const category = req.query.category as string | undefined;
|
||||||
|
|
||||||
|
const result = await brandService.getBrandPromotionalHistory(brandName, {
|
||||||
|
window,
|
||||||
|
stateCode,
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AnalyticsV2] Brand promotions error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch brand promotional history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /brand/:name/intelligence
|
||||||
|
* Get comprehensive B2B brand intelligence dashboard data
|
||||||
|
*
|
||||||
|
* Returns all brand metrics in a single unified response:
|
||||||
|
* - Performance Snapshot (active SKUs, revenue, stores, market share)
|
||||||
|
* - Alerts/Slippage (lost stores, delisted SKUs, competitor takeovers)
|
||||||
|
* - Product Velocity (daily rates, velocity status)
|
||||||
|
* - Retail Footprint (penetration, whitespace opportunities)
|
||||||
|
* - Competitive Landscape (price position, market share trend)
|
||||||
|
* - Inventory Health (days of stock, risk levels)
|
||||||
|
* - Promotion Effectiveness (baseline vs promo velocity, ROI)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - window: 7d|30d|90d (default: 30d)
|
||||||
|
* - state: state code filter (e.g., AZ)
|
||||||
|
* - category: category filter (e.g., Flower)
|
||||||
|
*/
|
||||||
|
router.get('/brand/:name/intelligence', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const brandName = decodeURIComponent(req.params.name);
|
||||||
|
const window = parseTimeWindow(req.query.window as string);
|
||||||
|
const stateCode = req.query.state as string | undefined;
|
||||||
|
const category = req.query.category as string | undefined;
|
||||||
|
|
||||||
|
const result = await brandIntelligenceService.getBrandIntelligence(brandName, {
|
||||||
|
window,
|
||||||
|
stateCode,
|
||||||
|
category,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ error: 'Brand not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AnalyticsV2] Brand intelligence error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch brand intelligence' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// CATEGORY ANALYTICS
|
// CATEGORY ANALYTICS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -400,6 +483,31 @@ export function createAnalyticsV2Router(pool: Pool): Router {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /store/:id/quantity-changes
|
||||||
|
* Get quantity changes for a store (increases/decreases)
|
||||||
|
* Useful for estimating sales (decreases) or restocks (increases)
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - window: 7d|30d|90d (default: 7d)
|
||||||
|
* - direction: increase|decrease|all (default: all)
|
||||||
|
* - limit: number (default: 100)
|
||||||
|
*/
|
||||||
|
router.get('/store/:id/quantity-changes', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const dispensaryId = parseInt(req.params.id);
|
||||||
|
const window = parseTimeWindow(req.query.window as string);
|
||||||
|
const direction = (req.query.direction as 'increase' | 'decrease' | 'all') || 'all';
|
||||||
|
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
|
||||||
|
|
||||||
|
const result = await storeService.getQuantityChanges(dispensaryId, { window, direction, limit });
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AnalyticsV2] Store quantity changes error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch store quantity changes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /store/:id/inventory
|
* GET /store/:id/inventory
|
||||||
* Get store inventory composition
|
* Get store inventory composition
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ router.get('/brands', async (req: Request, res: Response) => {
|
|||||||
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
array_agg(DISTINCT d.state) FILTER (WHERE d.state IS NOT NULL) as states,
|
||||||
COUNT(DISTINCT d.id) as store_count,
|
COUNT(DISTINCT d.id) as store_count,
|
||||||
COUNT(DISTINCT sp.id) as sku_count,
|
COUNT(DISTINCT sp.id) as sku_count,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) FILTER (WHERE sp.price_rec > 0) as avg_price_rec,
|
ROUND(AVG(sp.price_rec) FILTER (WHERE sp.price_rec > 0)::numeric, 2) as avg_price_rec,
|
||||||
ROUND(AVG(sp.price_med)::numeric, 2) FILTER (WHERE sp.price_med > 0) as avg_price_med
|
ROUND(AVG(sp.price_med) FILTER (WHERE sp.price_med > 0)::numeric, 2) as avg_price_med
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON sp.dispensary_id = d.id
|
JOIN dispensaries d ON sp.dispensary_id = d.id
|
||||||
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
WHERE sp.brand_name_raw IS NOT NULL AND sp.brand_name_raw != ''
|
||||||
@@ -154,10 +154,9 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
SELECT
|
SELECT
|
||||||
sp.category_raw as category,
|
sp.category_raw as category,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
MIN(sp.price_rec) as min_price,
|
||||||
MAX(sp.price_rec) as max_price,
|
MAX(sp.price_rec) as max_price,
|
||||||
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2)
|
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)::numeric, 2) as median_price,
|
||||||
FILTER (WHERE sp.price_rec > 0) as median_price,
|
|
||||||
COUNT(*) as product_count
|
COUNT(*) as product_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
WHERE sp.category_raw IS NOT NULL AND sp.price_rec > 0
|
||||||
@@ -169,7 +168,7 @@ router.get('/pricing', async (req: Request, res: Response) => {
|
|||||||
SELECT
|
SELECT
|
||||||
d.state,
|
d.state,
|
||||||
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
ROUND(AVG(sp.price_rec)::numeric, 2) as avg_price,
|
||||||
MIN(sp.price_rec) FILTER (WHERE sp.price_rec > 0) as min_price,
|
MIN(sp.price_rec) as min_price,
|
||||||
MAX(sp.price_rec) as max_price,
|
MAX(sp.price_rec) as max_price,
|
||||||
COUNT(DISTINCT sp.id) as product_count
|
COUNT(DISTINCT sp.id) as product_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
|
|||||||
@@ -183,8 +183,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
|
|||||||
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
|
return res.status(400).json({ error: 'Concurrency must be between 1 and 50' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = await createProxyTestJob(mode, concurrency);
|
const { jobId, totalProxies } = await createProxyTestJob(mode, concurrency);
|
||||||
res.json({ jobId, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
|
res.json({ jobId, total: totalProxies, mode, concurrency, message: `Proxy test job started (mode: ${mode}, concurrency: ${concurrency})` });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error starting proxy test job:', error);
|
console.error('Error starting proxy test job:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
||||||
@@ -195,8 +195,8 @@ router.post('/test-all', requireRole('superadmin', 'admin'), async (req, res) =>
|
|||||||
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
|
router.post('/test-failed', requireRole('superadmin', 'admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const concurrency = parseInt(req.query.concurrency as string) || 10;
|
const concurrency = parseInt(req.query.concurrency as string) || 10;
|
||||||
const jobId = await createProxyTestJob('failed', concurrency);
|
const { jobId, totalProxies } = await createProxyTestJob('failed', concurrency);
|
||||||
res.json({ jobId, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
|
res.json({ jobId, total: totalProxies, mode: 'failed', concurrency, message: 'Retesting failed proxies...' });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error starting failed proxy test:', error);
|
console.error('Error starting failed proxy test:', error);
|
||||||
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
res.status(500).json({ error: error.message || 'Failed to start proxy test job' });
|
||||||
|
|||||||
@@ -130,6 +130,12 @@ const CONSUMER_TRUSTED_ORIGINS = [
|
|||||||
'http://localhost:3002',
|
'http://localhost:3002',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Wildcard trusted origin patterns (*.domain.com)
|
||||||
|
const CONSUMER_TRUSTED_PATTERNS = [
|
||||||
|
/^https:\/\/([a-z0-9-]+\.)?cannaiq\.co$/,
|
||||||
|
/^https:\/\/([a-z0-9-]+\.)?cannabrands\.app$/,
|
||||||
|
];
|
||||||
|
|
||||||
// Trusted IPs for local development (bypass API key auth)
|
// Trusted IPs for local development (bypass API key auth)
|
||||||
const TRUSTED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
|
const TRUSTED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1'];
|
||||||
|
|
||||||
@@ -150,9 +156,18 @@ function isConsumerTrustedRequest(req: Request): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const origin = req.headers.origin;
|
const origin = req.headers.origin;
|
||||||
if (origin && CONSUMER_TRUSTED_ORIGINS.includes(origin)) {
|
if (origin) {
|
||||||
|
// Check exact matches
|
||||||
|
if (CONSUMER_TRUSTED_ORIGINS.includes(origin)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Check wildcard patterns
|
||||||
|
for (const pattern of CONSUMER_TRUSTED_PATTERNS) {
|
||||||
|
if (pattern.test(origin)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
const referer = req.headers.referer;
|
const referer = req.headers.referer;
|
||||||
if (referer) {
|
if (referer) {
|
||||||
for (const trusted of CONSUMER_TRUSTED_ORIGINS) {
|
for (const trusted of CONSUMER_TRUSTED_ORIGINS) {
|
||||||
@@ -160,6 +175,18 @@ function isConsumerTrustedRequest(req: Request): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Check wildcard patterns against referer origin
|
||||||
|
try {
|
||||||
|
const refererUrl = new URL(referer);
|
||||||
|
const refererOrigin = refererUrl.origin;
|
||||||
|
for (const pattern of CONSUMER_TRUSTED_PATTERNS) {
|
||||||
|
if (pattern.test(refererOrigin)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid referer URL, ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,23 +14,36 @@ router.get('/', async (req: AuthRequest, res) => {
|
|||||||
try {
|
try {
|
||||||
const { search, domain } = req.query;
|
const { search, domain } = req.query;
|
||||||
|
|
||||||
let query = `
|
// Check which columns exist (schema-tolerant)
|
||||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
const columnsResult = await pool.query(`
|
||||||
FROM users
|
SELECT column_name FROM information_schema.columns
|
||||||
WHERE 1=1
|
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
||||||
`;
|
`);
|
||||||
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||||
|
|
||||||
|
// Build column list based on what exists
|
||||||
|
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
||||||
|
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
||||||
|
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
||||||
|
if (existingColumns.has('phone')) selectCols.push('phone');
|
||||||
|
if (existingColumns.has('domain')) selectCols.push('domain');
|
||||||
|
|
||||||
|
let query = `SELECT ${selectCols.join(', ')} FROM users WHERE 1=1`;
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
|
|
||||||
// Search by email, first_name, or last_name
|
// Search by email (and optionally first_name, last_name if they exist)
|
||||||
if (search && typeof search === 'string') {
|
if (search && typeof search === 'string') {
|
||||||
query += ` AND (email ILIKE $${paramIndex} OR first_name ILIKE $${paramIndex} OR last_name ILIKE $${paramIndex})`;
|
const searchClauses = ['email ILIKE $' + paramIndex];
|
||||||
|
if (existingColumns.has('first_name')) searchClauses.push('first_name ILIKE $' + paramIndex);
|
||||||
|
if (existingColumns.has('last_name')) searchClauses.push('last_name ILIKE $' + paramIndex);
|
||||||
|
query += ` AND (${searchClauses.join(' OR ')})`;
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by domain
|
// Filter by domain (if column exists)
|
||||||
if (domain && typeof domain === 'string') {
|
if (domain && typeof domain === 'string' && existingColumns.has('domain')) {
|
||||||
query += ` AND domain = $${paramIndex}`;
|
query += ` AND domain = $${paramIndex}`;
|
||||||
params.push(domain);
|
params.push(domain);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
@@ -50,8 +63,22 @@ router.get('/', async (req: AuthRequest, res) => {
|
|||||||
router.get('/:id', async (req: AuthRequest, res) => {
|
router.get('/:id', async (req: AuthRequest, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check which columns exist (schema-tolerant)
|
||||||
|
const columnsResult = await pool.query(`
|
||||||
|
SELECT column_name FROM information_schema.columns
|
||||||
|
WHERE table_name = 'users' AND column_name IN ('first_name', 'last_name', 'phone', 'domain')
|
||||||
|
`);
|
||||||
|
const existingColumns = new Set(columnsResult.rows.map((r: any) => r.column_name));
|
||||||
|
|
||||||
|
const selectCols = ['id', 'email', 'role', 'created_at', 'updated_at'];
|
||||||
|
if (existingColumns.has('first_name')) selectCols.push('first_name');
|
||||||
|
if (existingColumns.has('last_name')) selectCols.push('last_name');
|
||||||
|
if (existingColumns.has('phone')) selectCols.push('phone');
|
||||||
|
if (existingColumns.has('domain')) selectCols.push('domain');
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT id, email, role, first_name, last_name, phone, domain, created_at, updated_at
|
SELECT ${selectCols.join(', ')}
|
||||||
FROM users
|
FROM users
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|||||||
@@ -273,6 +273,29 @@ router.post('/deregister', async (req: Request, res: Response) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/workers', async (req: Request, res: Response) => {
|
router.get('/workers', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
// Check if worker_registry table exists
|
||||||
|
const tableCheck = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_name = 'worker_registry'
|
||||||
|
) as exists
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!tableCheck.rows[0].exists) {
|
||||||
|
// Return empty result if table doesn't exist yet
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
workers: [],
|
||||||
|
summary: {
|
||||||
|
active_count: 0,
|
||||||
|
idle_count: 0,
|
||||||
|
offline_count: 0,
|
||||||
|
total_count: 0,
|
||||||
|
active_roles: 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { status, role, include_terminated = 'false' } = req.query;
|
const { status, role, include_terminated = 'false' } = req.query;
|
||||||
|
|
||||||
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";
|
let whereClause = include_terminated === 'true' ? 'WHERE 1=1' : "WHERE status != 'terminated'";
|
||||||
|
|||||||
1202
backend/src/services/analytics/BrandIntelligenceService.ts
Normal file
1202
backend/src/services/analytics/BrandIntelligenceService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,8 @@ import {
|
|||||||
PenetrationDataPoint,
|
PenetrationDataPoint,
|
||||||
BrandMarketPosition,
|
BrandMarketPosition,
|
||||||
BrandRecVsMedFootprint,
|
BrandRecVsMedFootprint,
|
||||||
|
BrandPromotionalSummary,
|
||||||
|
BrandPromotionalEvent,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export class BrandPenetrationService {
|
export class BrandPenetrationService {
|
||||||
@@ -44,16 +46,17 @@ export class BrandPenetrationService {
|
|||||||
// Get current brand presence
|
// Get current brand presence
|
||||||
const currentResult = await this.pool.query(`
|
const currentResult = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name,
|
sp.brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries,
|
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries,
|
||||||
COUNT(*) AS total_skus,
|
COUNT(*) AS total_skus,
|
||||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus_per_dispensary,
|
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus_per_dispensary,
|
||||||
ARRAY_AGG(DISTINCT s.code) FILTER (WHERE s.code IS NOT NULL) AS states_present
|
ARRAY_AGG(DISTINCT s.code) FILTER (WHERE s.code IS NOT NULL) AS states_present
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
LEFT JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY sp.brand_name
|
GROUP BY sp.brand_name_raw
|
||||||
`, [brandName]);
|
`, [brandName]);
|
||||||
|
|
||||||
if (currentResult.rows.length === 0) {
|
if (currentResult.rows.length === 0) {
|
||||||
@@ -72,7 +75,7 @@ export class BrandPenetrationService {
|
|||||||
DATE(sps.captured_at) AS date,
|
DATE(sps.captured_at) AS date,
|
||||||
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
|
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
|
||||||
FROM store_product_snapshots sps
|
FROM store_product_snapshots sps
|
||||||
WHERE sps.brand_name = $1
|
WHERE sps.brand_name_raw = $1
|
||||||
AND sps.captured_at >= $2
|
AND sps.captured_at >= $2
|
||||||
AND sps.captured_at <= $3
|
AND sps.captured_at <= $3
|
||||||
AND sps.is_in_stock = TRUE
|
AND sps.is_in_stock = TRUE
|
||||||
@@ -123,8 +126,9 @@ export class BrandPenetrationService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
COUNT(*) AS sku_count
|
COUNT(*) AS sku_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
||||||
),
|
),
|
||||||
@@ -133,7 +137,8 @@ export class BrandPenetrationService {
|
|||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries
|
COUNT(DISTINCT sp.dispensary_id) AS total_dispensaries
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE sp.is_in_stock = TRUE
|
WHERE sp.is_in_stock = TRUE
|
||||||
GROUP BY s.code
|
GROUP BY s.code
|
||||||
)
|
)
|
||||||
@@ -169,7 +174,7 @@ export class BrandPenetrationService {
|
|||||||
let filters = '';
|
let filters = '';
|
||||||
|
|
||||||
if (options.category) {
|
if (options.category) {
|
||||||
filters += ` AND sp.category = $${paramIdx}`;
|
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||||
params.push(options.category);
|
params.push(options.category);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
@@ -183,31 +188,33 @@ export class BrandPenetrationService {
|
|||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
WITH brand_metrics AS (
|
WITH brand_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name,
|
sp.brand_name_raw AS brand_name,
|
||||||
sp.category,
|
sp.category_raw AS category,
|
||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
AVG(sp.price_rec) AS avg_price
|
AVG(sp.price_rec) AS avg_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
${filters}
|
${filters}
|
||||||
GROUP BY sp.brand_name, sp.category, s.code
|
GROUP BY sp.brand_name_raw, sp.category_raw, s.code
|
||||||
),
|
),
|
||||||
category_totals AS (
|
category_totals AS (
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw AS category,
|
||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
COUNT(*) AS total_skus,
|
COUNT(*) AS total_skus,
|
||||||
AVG(sp.price_rec) AS category_avg_price
|
AVG(sp.price_rec) AS category_avg_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE sp.is_in_stock = TRUE
|
WHERE sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
GROUP BY sp.category, s.code
|
GROUP BY sp.category_raw, s.code
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
bm.*,
|
bm.*,
|
||||||
@@ -243,8 +250,9 @@ export class BrandPenetrationService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND s.recreational_legal = TRUE
|
AND s.recreational_legal = TRUE
|
||||||
),
|
),
|
||||||
@@ -255,8 +263,9 @@ export class BrandPenetrationService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
ROUND(COUNT(*)::NUMERIC / NULLIF(COUNT(DISTINCT sp.dispensary_id), 0), 2) AS avg_skus
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND s.medical_legal = TRUE
|
AND s.medical_legal = TRUE
|
||||||
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
||||||
@@ -311,23 +320,24 @@ export class BrandPenetrationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
filters += ` AND sp.category = $${paramIdx}`;
|
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||||
params.push(category);
|
params.push(category);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name,
|
sp.brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT s.code) AS state_count
|
COUNT(DISTINCT s.code) AS state_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.brand_name IS NOT NULL
|
LEFT JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.brand_name_raw IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
${filters}
|
${filters}
|
||||||
GROUP BY sp.brand_name
|
GROUP BY sp.brand_name_raw
|
||||||
ORDER BY dispensary_count DESC, sku_count DESC
|
ORDER BY dispensary_count DESC, sku_count DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`, params);
|
`, params);
|
||||||
@@ -358,23 +368,23 @@ export class BrandPenetrationService {
|
|||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
WITH start_counts AS (
|
WITH start_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
brand_name,
|
brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
||||||
FROM store_product_snapshots
|
FROM store_product_snapshots
|
||||||
WHERE captured_at >= $1 AND captured_at < $1 + INTERVAL '1 day'
|
WHERE captured_at >= $1 AND captured_at < $1 + INTERVAL '1 day'
|
||||||
AND brand_name IS NOT NULL
|
AND brand_name_raw IS NOT NULL
|
||||||
AND is_in_stock = TRUE
|
AND is_in_stock = TRUE
|
||||||
GROUP BY brand_name
|
GROUP BY brand_name_raw
|
||||||
),
|
),
|
||||||
end_counts AS (
|
end_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
brand_name,
|
brand_name_raw AS brand_name,
|
||||||
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
COUNT(DISTINCT dispensary_id) AS dispensary_count
|
||||||
FROM store_product_snapshots
|
FROM store_product_snapshots
|
||||||
WHERE captured_at >= $2 - INTERVAL '1 day' AND captured_at <= $2
|
WHERE captured_at >= $2 - INTERVAL '1 day' AND captured_at <= $2
|
||||||
AND brand_name IS NOT NULL
|
AND brand_name_raw IS NOT NULL
|
||||||
AND is_in_stock = TRUE
|
AND is_in_stock = TRUE
|
||||||
GROUP BY brand_name
|
GROUP BY brand_name_raw
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(sc.brand_name, ec.brand_name) AS brand_name,
|
COALESCE(sc.brand_name, ec.brand_name) AS brand_name,
|
||||||
@@ -401,6 +411,225 @@ export class BrandPenetrationService {
|
|||||||
change_percent: row.change_percent ? parseFloat(row.change_percent) : 0,
|
change_percent: row.change_percent ? parseFloat(row.change_percent) : 0,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brand promotional history
|
||||||
|
*
|
||||||
|
* Tracks when products went on special, how long, what discount,
|
||||||
|
* and estimated quantity sold during the promotion.
|
||||||
|
*/
|
||||||
|
async getBrandPromotionalHistory(
|
||||||
|
brandName: string,
|
||||||
|
options: { window?: TimeWindow; customRange?: DateRange; stateCode?: string; category?: string } = {}
|
||||||
|
): Promise<BrandPromotionalSummary> {
|
||||||
|
const { window = '90d', customRange, stateCode, category } = options;
|
||||||
|
const { start, end } = getDateRangeFromWindow(window, customRange);
|
||||||
|
|
||||||
|
// Build filters
|
||||||
|
const params: any[] = [brandName, start, end];
|
||||||
|
let paramIdx = 4;
|
||||||
|
let filters = '';
|
||||||
|
|
||||||
|
if (stateCode) {
|
||||||
|
filters += ` AND s.code = $${paramIdx}`;
|
||||||
|
params.push(stateCode);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||||
|
params.push(category);
|
||||||
|
paramIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find promotional events by detecting when is_on_special transitions to TRUE
|
||||||
|
// and tracking until it transitions back to FALSE
|
||||||
|
const eventsResult = await this.pool.query(`
|
||||||
|
WITH snapshot_with_lag AS (
|
||||||
|
SELECT
|
||||||
|
sps.id,
|
||||||
|
sps.store_product_id,
|
||||||
|
sps.dispensary_id,
|
||||||
|
sps.brand_name_raw,
|
||||||
|
sps.name_raw,
|
||||||
|
sps.category_raw,
|
||||||
|
sps.is_on_special,
|
||||||
|
sps.price_rec,
|
||||||
|
sps.price_rec_special,
|
||||||
|
sps.stock_quantity,
|
||||||
|
sps.captured_at,
|
||||||
|
LAG(sps.is_on_special) OVER (
|
||||||
|
PARTITION BY sps.store_product_id
|
||||||
|
ORDER BY sps.captured_at
|
||||||
|
) AS prev_is_on_special,
|
||||||
|
LAG(sps.stock_quantity) OVER (
|
||||||
|
PARTITION BY sps.store_product_id
|
||||||
|
ORDER BY sps.captured_at
|
||||||
|
) AS prev_stock_quantity
|
||||||
|
FROM store_product_snapshots sps
|
||||||
|
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||||
|
JOIN dispensaries dd ON dd.id = sp.dispensary_id
|
||||||
|
LEFT JOIN states s ON s.id = dd.state_id
|
||||||
|
WHERE sps.brand_name_raw = $1
|
||||||
|
AND sps.captured_at >= $2
|
||||||
|
AND sps.captured_at <= $3
|
||||||
|
${filters}
|
||||||
|
),
|
||||||
|
special_starts AS (
|
||||||
|
-- Find when specials START (transition from not-on-special to on-special)
|
||||||
|
SELECT
|
||||||
|
store_product_id,
|
||||||
|
dispensary_id,
|
||||||
|
name_raw,
|
||||||
|
category_raw,
|
||||||
|
captured_at AS special_start,
|
||||||
|
price_rec AS regular_price,
|
||||||
|
price_rec_special AS special_price,
|
||||||
|
stock_quantity AS quantity_at_start
|
||||||
|
FROM snapshot_with_lag
|
||||||
|
WHERE is_on_special = TRUE
|
||||||
|
AND (prev_is_on_special = FALSE OR prev_is_on_special IS NULL)
|
||||||
|
AND price_rec_special IS NOT NULL
|
||||||
|
AND price_rec IS NOT NULL
|
||||||
|
),
|
||||||
|
special_ends AS (
|
||||||
|
-- Find when specials END (transition from on-special to not-on-special)
|
||||||
|
SELECT
|
||||||
|
store_product_id,
|
||||||
|
captured_at AS special_end,
|
||||||
|
prev_stock_quantity AS quantity_at_end
|
||||||
|
FROM snapshot_with_lag
|
||||||
|
WHERE is_on_special = FALSE
|
||||||
|
AND prev_is_on_special = TRUE
|
||||||
|
),
|
||||||
|
matched_events AS (
|
||||||
|
SELECT
|
||||||
|
ss.store_product_id,
|
||||||
|
ss.dispensary_id,
|
||||||
|
ss.name_raw AS product_name,
|
||||||
|
ss.category_raw AS category,
|
||||||
|
ss.special_start,
|
||||||
|
se.special_end,
|
||||||
|
ss.regular_price,
|
||||||
|
ss.special_price,
|
||||||
|
ss.quantity_at_start,
|
||||||
|
COALESCE(se.quantity_at_end, ss.quantity_at_start) AS quantity_at_end
|
||||||
|
FROM special_starts ss
|
||||||
|
LEFT JOIN special_ends se ON se.store_product_id = ss.store_product_id
|
||||||
|
AND se.special_end > ss.special_start
|
||||||
|
AND se.special_end = (
|
||||||
|
SELECT MIN(se2.special_end)
|
||||||
|
FROM special_ends se2
|
||||||
|
WHERE se2.store_product_id = ss.store_product_id
|
||||||
|
AND se2.special_end > ss.special_start
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
me.store_product_id,
|
||||||
|
me.dispensary_id,
|
||||||
|
d.name AS dispensary_name,
|
||||||
|
s.code AS state_code,
|
||||||
|
me.product_name,
|
||||||
|
me.category,
|
||||||
|
me.special_start,
|
||||||
|
me.special_end,
|
||||||
|
EXTRACT(DAY FROM COALESCE(me.special_end, NOW()) - me.special_start)::INT AS duration_days,
|
||||||
|
me.regular_price,
|
||||||
|
me.special_price,
|
||||||
|
ROUND(((me.regular_price - me.special_price) / NULLIF(me.regular_price, 0)) * 100, 1) AS discount_percent,
|
||||||
|
me.quantity_at_start,
|
||||||
|
me.quantity_at_end,
|
||||||
|
GREATEST(0, COALESCE(me.quantity_at_start, 0) - COALESCE(me.quantity_at_end, 0)) AS quantity_sold_estimate
|
||||||
|
FROM matched_events me
|
||||||
|
JOIN dispensaries d ON d.id = me.dispensary_id
|
||||||
|
LEFT JOIN states s ON s.id = d.state_id
|
||||||
|
ORDER BY me.special_start DESC
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
const events: BrandPromotionalEvent[] = eventsResult.rows.map((row: any) => ({
|
||||||
|
product_name: row.product_name,
|
||||||
|
store_product_id: parseInt(row.store_product_id),
|
||||||
|
dispensary_id: parseInt(row.dispensary_id),
|
||||||
|
dispensary_name: row.dispensary_name,
|
||||||
|
state_code: row.state_code || 'Unknown',
|
||||||
|
category: row.category,
|
||||||
|
special_start: row.special_start.toISOString().split('T')[0],
|
||||||
|
special_end: row.special_end ? row.special_end.toISOString().split('T')[0] : null,
|
||||||
|
duration_days: row.duration_days ? parseInt(row.duration_days) : null,
|
||||||
|
regular_price: parseFloat(row.regular_price) || 0,
|
||||||
|
special_price: parseFloat(row.special_price) || 0,
|
||||||
|
discount_percent: parseFloat(row.discount_percent) || 0,
|
||||||
|
quantity_at_start: row.quantity_at_start ? parseInt(row.quantity_at_start) : null,
|
||||||
|
quantity_at_end: row.quantity_at_end ? parseInt(row.quantity_at_end) : null,
|
||||||
|
quantity_sold_estimate: row.quantity_sold_estimate ? parseInt(row.quantity_sold_estimate) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Calculate summary stats
|
||||||
|
const totalEvents = events.length;
|
||||||
|
const uniqueProducts = new Set(events.map(e => e.store_product_id)).size;
|
||||||
|
const uniqueDispensaries = new Set(events.map(e => e.dispensary_id)).size;
|
||||||
|
const uniqueStates = [...new Set(events.map(e => e.state_code))];
|
||||||
|
|
||||||
|
const avgDiscount = totalEvents > 0
|
||||||
|
? events.reduce((sum, e) => sum + e.discount_percent, 0) / totalEvents
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const durations = events.filter(e => e.duration_days !== null).map(e => e.duration_days!);
|
||||||
|
const avgDuration = durations.length > 0
|
||||||
|
? durations.reduce((sum, d) => sum + d, 0) / durations.length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const totalQuantitySold = events
|
||||||
|
.filter(e => e.quantity_sold_estimate !== null)
|
||||||
|
.reduce((sum, e) => sum + (e.quantity_sold_estimate || 0), 0);
|
||||||
|
|
||||||
|
// Calculate frequency
|
||||||
|
const windowDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const weeklyAvg = windowDays > 0 ? (totalEvents / windowDays) * 7 : 0;
|
||||||
|
const monthlyAvg = windowDays > 0 ? (totalEvents / windowDays) * 30 : 0;
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
const categoryMap = new Map<string, { count: number; discounts: number[]; quantity: number }>();
|
||||||
|
for (const event of events) {
|
||||||
|
const cat = event.category || 'Uncategorized';
|
||||||
|
if (!categoryMap.has(cat)) {
|
||||||
|
categoryMap.set(cat, { count: 0, discounts: [], quantity: 0 });
|
||||||
|
}
|
||||||
|
const entry = categoryMap.get(cat)!;
|
||||||
|
entry.count++;
|
||||||
|
entry.discounts.push(event.discount_percent);
|
||||||
|
if (event.quantity_sold_estimate !== null) {
|
||||||
|
entry.quantity += event.quantity_sold_estimate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const byCategory = Array.from(categoryMap.entries()).map(([category, data]) => ({
|
||||||
|
category,
|
||||||
|
event_count: data.count,
|
||||||
|
avg_discount_percent: data.discounts.length > 0
|
||||||
|
? Math.round((data.discounts.reduce((a, b) => a + b, 0) / data.discounts.length) * 10) / 10
|
||||||
|
: 0,
|
||||||
|
quantity_sold_estimate: data.quantity > 0 ? data.quantity : null,
|
||||||
|
})).sort((a, b) => b.event_count - a.event_count);
|
||||||
|
|
||||||
|
return {
|
||||||
|
brand_name: brandName,
|
||||||
|
window,
|
||||||
|
total_promotional_events: totalEvents,
|
||||||
|
total_products_on_special: uniqueProducts,
|
||||||
|
total_dispensaries_with_specials: uniqueDispensaries,
|
||||||
|
states_with_specials: uniqueStates,
|
||||||
|
avg_discount_percent: Math.round(avgDiscount * 10) / 10,
|
||||||
|
avg_duration_days: avgDuration !== null ? Math.round(avgDuration * 10) / 10 : null,
|
||||||
|
total_quantity_sold_estimate: totalQuantitySold > 0 ? totalQuantitySold : null,
|
||||||
|
promotional_frequency: {
|
||||||
|
weekly_avg: Math.round(weeklyAvg * 10) / 10,
|
||||||
|
monthly_avg: Math.round(monthlyAvg * 10) / 10,
|
||||||
|
},
|
||||||
|
by_category: byCategory,
|
||||||
|
events,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BrandPenetrationService;
|
export default BrandPenetrationService;
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ export class CategoryAnalyticsService {
|
|||||||
// Get current category metrics
|
// Get current category metrics
|
||||||
const currentResult = await this.pool.query(`
|
const currentResult = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
AVG(sp.price_rec) AS avg_price
|
AVG(sp.price_rec) AS avg_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
WHERE sp.category = $1
|
WHERE sp.category_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY sp.category
|
GROUP BY sp.category_raw
|
||||||
`, [category]);
|
`, [category]);
|
||||||
|
|
||||||
if (currentResult.rows.length === 0) {
|
if (currentResult.rows.length === 0) {
|
||||||
@@ -70,7 +70,7 @@ export class CategoryAnalyticsService {
|
|||||||
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count,
|
||||||
AVG(sps.price_rec) AS avg_price
|
AVG(sps.price_rec) AS avg_price
|
||||||
FROM store_product_snapshots sps
|
FROM store_product_snapshots sps
|
||||||
WHERE sps.category = $1
|
WHERE sps.category_raw = $1
|
||||||
AND sps.captured_at >= $2
|
AND sps.captured_at >= $2
|
||||||
AND sps.captured_at <= $3
|
AND sps.captured_at <= $3
|
||||||
AND sps.is_in_stock = TRUE
|
AND sps.is_in_stock = TRUE
|
||||||
@@ -111,8 +111,9 @@ export class CategoryAnalyticsService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
AVG(sp.price_rec) AS avg_price
|
AVG(sp.price_rec) AS avg_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.category = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.category_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY s.code, s.name, s.recreational_legal
|
GROUP BY s.code, s.name, s.recreational_legal
|
||||||
ORDER BY sku_count DESC
|
ORDER BY sku_count DESC
|
||||||
@@ -154,24 +155,25 @@ export class CategoryAnalyticsService {
|
|||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
COUNT(DISTINCT sp.brand_name) AS brand_count,
|
COUNT(DISTINCT sp.brand_name_raw) AS brand_count,
|
||||||
AVG(sp.price_rec) AS avg_price,
|
AVG(sp.price_rec) AS avg_price,
|
||||||
COUNT(DISTINCT s.code) AS state_count
|
COUNT(DISTINCT s.code) AS state_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.category IS NOT NULL
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.category_raw IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
${stateFilter}
|
${stateFilter}
|
||||||
GROUP BY sp.category
|
GROUP BY sp.category_raw
|
||||||
ORDER BY sku_count DESC
|
ORDER BY sku_count DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
sku_count: parseInt(row.sku_count),
|
sku_count: parseInt(row.sku_count),
|
||||||
dispensary_count: parseInt(row.dispensary_count),
|
dispensary_count: parseInt(row.dispensary_count),
|
||||||
brand_count: parseInt(row.brand_count),
|
brand_count: parseInt(row.brand_count),
|
||||||
@@ -188,14 +190,14 @@ export class CategoryAnalyticsService {
|
|||||||
let categoryFilter = '';
|
let categoryFilter = '';
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
categoryFilter = 'WHERE sp.category = $1';
|
categoryFilter = 'WHERE sp.category_raw = $1';
|
||||||
params.push(category);
|
params.push(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
WITH category_stats AS (
|
WITH category_stats AS (
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END AS legal_type,
|
CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END AS legal_type,
|
||||||
COUNT(DISTINCT s.code) AS state_count,
|
COUNT(DISTINCT s.code) AS state_count,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
@@ -203,13 +205,14 @@ export class CategoryAnalyticsService {
|
|||||||
AVG(sp.price_rec) AS avg_price,
|
AVG(sp.price_rec) AS avg_price,
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
${categoryFilter}
|
${categoryFilter}
|
||||||
${category ? 'AND' : 'WHERE'} sp.category IS NOT NULL
|
${category ? 'AND' : 'WHERE'} sp.category_raw IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.price_rec IS NOT NULL
|
AND sp.price_rec IS NOT NULL
|
||||||
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
||||||
GROUP BY sp.category, CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END
|
GROUP BY sp.category_raw, CASE WHEN s.recreational_legal = TRUE THEN 'recreational' ELSE 'medical_only' END
|
||||||
),
|
),
|
||||||
rec_stats AS (
|
rec_stats AS (
|
||||||
SELECT * FROM category_stats WHERE legal_type = 'recreational'
|
SELECT * FROM category_stats WHERE legal_type = 'recreational'
|
||||||
@@ -218,7 +221,7 @@ export class CategoryAnalyticsService {
|
|||||||
SELECT * FROM category_stats WHERE legal_type = 'medical_only'
|
SELECT * FROM category_stats WHERE legal_type = 'medical_only'
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(r.category, m.category) AS category,
|
COALESCE(r.category_raw, m.category_raw) AS category,
|
||||||
r.state_count AS rec_state_count,
|
r.state_count AS rec_state_count,
|
||||||
r.dispensary_count AS rec_dispensary_count,
|
r.dispensary_count AS rec_dispensary_count,
|
||||||
r.sku_count AS rec_sku_count,
|
r.sku_count AS rec_sku_count,
|
||||||
@@ -235,7 +238,7 @@ export class CategoryAnalyticsService {
|
|||||||
ELSE NULL
|
ELSE NULL
|
||||||
END AS price_diff_percent
|
END AS price_diff_percent
|
||||||
FROM rec_stats r
|
FROM rec_stats r
|
||||||
FULL OUTER JOIN med_stats m ON r.category = m.category
|
FULL OUTER JOIN med_stats m ON r.category_raw = m.category_raw
|
||||||
ORDER BY COALESCE(r.sku_count, 0) + COALESCE(m.sku_count, 0) DESC
|
ORDER BY COALESCE(r.sku_count, 0) + COALESCE(m.sku_count, 0) DESC
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
@@ -282,7 +285,7 @@ export class CategoryAnalyticsService {
|
|||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
|
COUNT(DISTINCT sps.dispensary_id) AS dispensary_count
|
||||||
FROM store_product_snapshots sps
|
FROM store_product_snapshots sps
|
||||||
WHERE sps.category = $1
|
WHERE sps.category_raw = $1
|
||||||
AND sps.captured_at >= $2
|
AND sps.captured_at >= $2
|
||||||
AND sps.captured_at <= $3
|
AND sps.captured_at <= $3
|
||||||
AND sps.is_in_stock = TRUE
|
AND sps.is_in_stock = TRUE
|
||||||
@@ -335,31 +338,33 @@ export class CategoryAnalyticsService {
|
|||||||
WITH category_total AS (
|
WITH category_total AS (
|
||||||
SELECT COUNT(*) AS total
|
SELECT COUNT(*) AS total
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.category = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.category_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.brand_name IS NOT NULL
|
AND sp.brand_name_raw IS NOT NULL
|
||||||
${stateFilter}
|
${stateFilter}
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name,
|
sp.brand_name_raw,
|
||||||
COUNT(*) AS sku_count,
|
COUNT(*) AS sku_count,
|
||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count,
|
||||||
AVG(sp.price_rec) AS avg_price,
|
AVG(sp.price_rec) AS avg_price,
|
||||||
ROUND(COUNT(*)::NUMERIC * 100 / NULLIF((SELECT total FROM category_total), 0), 2) AS category_share_percent
|
ROUND(COUNT(*)::NUMERIC * 100 / NULLIF((SELECT total FROM category_total), 0), 2) AS category_share_percent
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
WHERE sp.category = $1
|
JOIN states s ON s.id = d.state_id
|
||||||
|
WHERE sp.category_raw = $1
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.brand_name IS NOT NULL
|
AND sp.brand_name_raw IS NOT NULL
|
||||||
${stateFilter}
|
${stateFilter}
|
||||||
GROUP BY sp.brand_name
|
GROUP BY sp.brand_name_raw
|
||||||
ORDER BY sku_count DESC
|
ORDER BY sku_count DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
brand_name: row.brand_name,
|
brand_name: row.brand_name_raw,
|
||||||
sku_count: parseInt(row.sku_count),
|
sku_count: parseInt(row.sku_count),
|
||||||
dispensary_count: parseInt(row.dispensary_count),
|
dispensary_count: parseInt(row.dispensary_count),
|
||||||
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
|
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
|
||||||
@@ -421,7 +426,7 @@ export class CategoryAnalyticsService {
|
|||||||
`, [start, end, limit]);
|
`, [start, end, limit]);
|
||||||
|
|
||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
start_sku_count: parseInt(row.start_sku_count),
|
start_sku_count: parseInt(row.start_sku_count),
|
||||||
end_sku_count: parseInt(row.end_sku_count),
|
end_sku_count: parseInt(row.end_sku_count),
|
||||||
growth: parseInt(row.growth),
|
growth: parseInt(row.growth),
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export class PriceAnalyticsService {
|
|||||||
const productResult = await this.pool.query(`
|
const productResult = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.id,
|
sp.id,
|
||||||
sp.name,
|
sp.name_raw,
|
||||||
sp.brand_name,
|
sp.brand_name_raw,
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
sp.dispensary_id,
|
sp.dispensary_id,
|
||||||
sp.price_rec,
|
sp.price_rec,
|
||||||
sp.price_med,
|
sp.price_med,
|
||||||
@@ -53,7 +53,7 @@ export class PriceAnalyticsService {
|
|||||||
s.code AS state_code
|
s.code AS state_code
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE sp.id = $1
|
WHERE sp.id = $1
|
||||||
`, [storeProductId]);
|
`, [storeProductId]);
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ export class PriceAnalyticsService {
|
|||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
s.name AS state_name,
|
s.name AS state_name,
|
||||||
CASE
|
CASE
|
||||||
@@ -148,18 +148,18 @@ export class PriceAnalyticsService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE sp.category = $1
|
WHERE sp.category_raw = $1
|
||||||
AND sp.price_rec IS NOT NULL
|
AND sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
||||||
${stateFilter}
|
${stateFilter}
|
||||||
GROUP BY sp.category, s.code, s.name, s.recreational_legal
|
GROUP BY sp.category_raw, s.code, s.name, s.recreational_legal
|
||||||
ORDER BY state_code
|
ORDER BY state_code
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
state_code: row.state_code,
|
state_code: row.state_code,
|
||||||
state_name: row.state_name,
|
state_name: row.state_name,
|
||||||
legal_type: row.legal_type,
|
legal_type: row.legal_type,
|
||||||
@@ -189,7 +189,7 @@ export class PriceAnalyticsService {
|
|||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name AS category,
|
sp.brand_name_raw AS category,
|
||||||
s.code AS state_code,
|
s.code AS state_code,
|
||||||
s.name AS state_name,
|
s.name AS state_name,
|
||||||
CASE
|
CASE
|
||||||
@@ -204,18 +204,18 @@ export class PriceAnalyticsService {
|
|||||||
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count
|
COUNT(DISTINCT sp.dispensary_id) AS dispensary_count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN dispensaries d ON d.id = sp.dispensary_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE sp.brand_name = $1
|
WHERE sp.brand_name_raw = $1
|
||||||
AND sp.price_rec IS NOT NULL
|
AND sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
||||||
${stateFilter}
|
${stateFilter}
|
||||||
GROUP BY sp.brand_name, s.code, s.name, s.recreational_legal
|
GROUP BY sp.brand_name_raw, s.code, s.name, s.recreational_legal
|
||||||
ORDER BY state_code
|
ORDER BY state_code
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
state_code: row.state_code,
|
state_code: row.state_code,
|
||||||
state_name: row.state_name,
|
state_name: row.state_name,
|
||||||
legal_type: row.legal_type,
|
legal_type: row.legal_type,
|
||||||
@@ -254,7 +254,7 @@ export class PriceAnalyticsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
filters += ` AND sp.category = $${paramIdx}`;
|
filters += ` AND sp.category_raw = $${paramIdx}`;
|
||||||
params.push(category);
|
params.push(category);
|
||||||
paramIdx++;
|
paramIdx++;
|
||||||
}
|
}
|
||||||
@@ -288,15 +288,16 @@ export class PriceAnalyticsService {
|
|||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
v.store_product_id,
|
v.store_product_id,
|
||||||
sp.name AS product_name,
|
sp.name_raw AS product_name,
|
||||||
sp.brand_name,
|
sp.brand_name_raw,
|
||||||
v.change_count,
|
v.change_count,
|
||||||
v.avg_change_pct,
|
v.avg_change_pct,
|
||||||
v.max_change_pct,
|
v.max_change_pct,
|
||||||
v.last_change_at
|
v.last_change_at
|
||||||
FROM volatility v
|
FROM volatility v
|
||||||
JOIN store_products sp ON sp.id = v.store_product_id
|
JOIN store_products sp ON sp.id = v.store_product_id
|
||||||
LEFT JOIN states s ON s.id = sp.state_id
|
LEFT JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE 1=1 ${filters}
|
WHERE 1=1 ${filters}
|
||||||
ORDER BY v.change_count DESC, v.avg_change_pct DESC
|
ORDER BY v.change_count DESC, v.avg_change_pct DESC
|
||||||
LIMIT $3
|
LIMIT $3
|
||||||
@@ -305,7 +306,7 @@ export class PriceAnalyticsService {
|
|||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
store_product_id: row.store_product_id,
|
store_product_id: row.store_product_id,
|
||||||
product_name: row.product_name,
|
product_name: row.product_name,
|
||||||
brand_name: row.brand_name,
|
brand_name: row.brand_name_raw,
|
||||||
change_count: parseInt(row.change_count),
|
change_count: parseInt(row.change_count),
|
||||||
avg_change_percent: row.avg_change_pct ? parseFloat(row.avg_change_pct) : 0,
|
avg_change_percent: row.avg_change_pct ? parseFloat(row.avg_change_pct) : 0,
|
||||||
max_change_percent: row.max_change_pct ? parseFloat(row.max_change_pct) : 0,
|
max_change_percent: row.max_change_pct ? parseFloat(row.max_change_pct) : 0,
|
||||||
@@ -327,13 +328,13 @@ export class PriceAnalyticsService {
|
|||||||
let categoryFilter = '';
|
let categoryFilter = '';
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
categoryFilter = 'WHERE sp.category = $1';
|
categoryFilter = 'WHERE sp.category_raw = $1';
|
||||||
params.push(category);
|
params.push(category);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
AVG(sp.price_rec) FILTER (WHERE s.recreational_legal = TRUE) AS rec_avg,
|
AVG(sp.price_rec) FILTER (WHERE s.recreational_legal = TRUE) AS rec_avg,
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)
|
||||||
FILTER (WHERE s.recreational_legal = TRUE) AS rec_median,
|
FILTER (WHERE s.recreational_legal = TRUE) AS rec_median,
|
||||||
@@ -343,17 +344,18 @@ export class PriceAnalyticsService {
|
|||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec)
|
||||||
FILTER (WHERE s.medical_legal = TRUE AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)) AS med_median
|
FILTER (WHERE s.medical_legal = TRUE AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)) AS med_median
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
${categoryFilter}
|
${categoryFilter}
|
||||||
${category ? 'AND' : 'WHERE'} sp.price_rec IS NOT NULL
|
${category ? 'AND' : 'WHERE'} sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
GROUP BY sp.category
|
GROUP BY sp.category_raw
|
||||||
ORDER BY sp.category
|
ORDER BY sp.category_raw
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
rec_avg: row.rec_avg ? parseFloat(row.rec_avg) : null,
|
rec_avg: row.rec_avg ? parseFloat(row.rec_avg) : null,
|
||||||
rec_median: row.rec_median ? parseFloat(row.rec_median) : null,
|
rec_median: row.rec_median ? parseFloat(row.rec_median) : null,
|
||||||
med_avg: row.med_avg ? parseFloat(row.med_avg) : null,
|
med_avg: row.med_avg ? parseFloat(row.med_avg) : null,
|
||||||
|
|||||||
@@ -108,14 +108,14 @@ export class StateAnalyticsService {
|
|||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT d.id) AS dispensary_count,
|
COUNT(DISTINCT d.id) AS dispensary_count,
|
||||||
COUNT(DISTINCT sp.id) AS product_count,
|
COUNT(DISTINCT sp.id) AS product_count,
|
||||||
COUNT(DISTINCT sp.brand_name) FILTER (WHERE sp.brand_name IS NOT NULL) AS brand_count,
|
COUNT(DISTINCT sp.brand_name_raw) FILTER (WHERE sp.brand_name_raw IS NOT NULL) AS brand_count,
|
||||||
COUNT(DISTINCT sp.category) FILTER (WHERE sp.category IS NOT NULL) AS category_count,
|
COUNT(DISTINCT sp.category_raw) FILTER (WHERE sp.category_raw IS NOT NULL) AS category_count,
|
||||||
COUNT(sps.id) AS snapshot_count,
|
COUNT(sps.id) AS snapshot_count,
|
||||||
MAX(sps.captured_at) AS last_crawl_at
|
MAX(sps.captured_at) AS last_crawl_at
|
||||||
FROM states s
|
FROM states s
|
||||||
LEFT JOIN dispensaries d ON d.state_id = s.id
|
LEFT JOIN dispensaries d ON d.state_id = s.id
|
||||||
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE
|
LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
|
||||||
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id
|
LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
|
||||||
WHERE s.code = $1
|
WHERE s.code = $1
|
||||||
`, [stateCode]);
|
`, [stateCode]);
|
||||||
|
|
||||||
@@ -129,7 +129,8 @@ export class StateAnalyticsService {
|
|||||||
MIN(price_rec) AS min_price,
|
MIN(price_rec) AS min_price,
|
||||||
MAX(price_rec) AS max_price
|
MAX(price_rec) AS max_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE s.code = $1
|
WHERE s.code = $1
|
||||||
AND sp.price_rec IS NOT NULL
|
AND sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
@@ -140,14 +141,15 @@ export class StateAnalyticsService {
|
|||||||
// Get top categories
|
// Get top categories
|
||||||
const topCategoriesResult = await this.pool.query(`
|
const topCategoriesResult = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
COUNT(*) AS count
|
COUNT(*) AS count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE s.code = $1
|
WHERE s.code = $1
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY sp.category
|
GROUP BY sp.category_raw
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [stateCode]);
|
`, [stateCode]);
|
||||||
@@ -155,14 +157,15 @@ export class StateAnalyticsService {
|
|||||||
// Get top brands
|
// Get top brands
|
||||||
const topBrandsResult = await this.pool.query(`
|
const topBrandsResult = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
sp.brand_name AS brand,
|
sp.brand_name_raw AS brand,
|
||||||
COUNT(*) AS count
|
COUNT(*) AS count
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE s.code = $1
|
WHERE s.code = $1
|
||||||
AND sp.brand_name IS NOT NULL
|
AND sp.brand_name_raw IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
GROUP BY sp.brand_name
|
GROUP BY sp.brand_name_raw
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [stateCode]);
|
`, [stateCode]);
|
||||||
@@ -191,7 +194,7 @@ export class StateAnalyticsService {
|
|||||||
max_price: pricing.max_price ? parseFloat(pricing.max_price) : null,
|
max_price: pricing.max_price ? parseFloat(pricing.max_price) : null,
|
||||||
},
|
},
|
||||||
top_categories: topCategoriesResult.rows.map((row: any) => ({
|
top_categories: topCategoriesResult.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
count: parseInt(row.count),
|
count: parseInt(row.count),
|
||||||
})),
|
})),
|
||||||
top_brands: topBrandsResult.rows.map((row: any) => ({
|
top_brands: topBrandsResult.rows.map((row: any) => ({
|
||||||
@@ -215,8 +218,8 @@ export class StateAnalyticsService {
|
|||||||
COUNT(sps.id) AS snapshot_count
|
COUNT(sps.id) AS snapshot_count
|
||||||
FROM states s
|
FROM states s
|
||||||
LEFT JOIN dispensaries d ON d.state_id = s.id
|
LEFT JOIN dispensaries d ON d.state_id = s.id
|
||||||
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE
|
LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
|
||||||
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id
|
LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
|
||||||
WHERE s.recreational_legal = TRUE
|
WHERE s.recreational_legal = TRUE
|
||||||
GROUP BY s.code, s.name
|
GROUP BY s.code, s.name
|
||||||
ORDER BY dispensary_count DESC
|
ORDER BY dispensary_count DESC
|
||||||
@@ -232,8 +235,8 @@ export class StateAnalyticsService {
|
|||||||
COUNT(sps.id) AS snapshot_count
|
COUNT(sps.id) AS snapshot_count
|
||||||
FROM states s
|
FROM states s
|
||||||
LEFT JOIN dispensaries d ON d.state_id = s.id
|
LEFT JOIN dispensaries d ON d.state_id = s.id
|
||||||
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE
|
LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
|
||||||
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id
|
LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
|
||||||
WHERE s.medical_legal = TRUE
|
WHERE s.medical_legal = TRUE
|
||||||
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
||||||
GROUP BY s.code, s.name
|
GROUP BY s.code, s.name
|
||||||
@@ -295,46 +298,48 @@ export class StateAnalyticsService {
|
|||||||
let groupBy = 'NULL';
|
let groupBy = 'NULL';
|
||||||
|
|
||||||
if (category) {
|
if (category) {
|
||||||
categoryFilter = 'AND sp.category = $1';
|
categoryFilter = 'AND sp.category_raw = $1';
|
||||||
params.push(category);
|
params.push(category);
|
||||||
groupBy = 'sp.category';
|
groupBy = 'sp.category_raw';
|
||||||
} else {
|
} else {
|
||||||
groupBy = 'sp.category';
|
groupBy = 'sp.category_raw';
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.pool.query(`
|
const result = await this.pool.query(`
|
||||||
WITH rec_prices AS (
|
WITH rec_prices AS (
|
||||||
SELECT
|
SELECT
|
||||||
${category ? 'sp.category' : 'sp.category'},
|
${category ? 'sp.category_raw' : 'sp.category_raw'},
|
||||||
COUNT(DISTINCT s.code) AS state_count,
|
COUNT(DISTINCT s.code) AS state_count,
|
||||||
COUNT(*) AS product_count,
|
COUNT(*) AS product_count,
|
||||||
AVG(sp.price_rec) AS avg_price,
|
AVG(sp.price_rec) AS avg_price,
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE s.recreational_legal = TRUE
|
WHERE s.recreational_legal = TRUE
|
||||||
AND sp.price_rec IS NOT NULL
|
AND sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
${categoryFilter}
|
${categoryFilter}
|
||||||
GROUP BY sp.category
|
GROUP BY sp.category_raw
|
||||||
),
|
),
|
||||||
med_prices AS (
|
med_prices AS (
|
||||||
SELECT
|
SELECT
|
||||||
${category ? 'sp.category' : 'sp.category'},
|
${category ? 'sp.category_raw' : 'sp.category_raw'},
|
||||||
COUNT(DISTINCT s.code) AS state_count,
|
COUNT(DISTINCT s.code) AS state_count,
|
||||||
COUNT(*) AS product_count,
|
COUNT(*) AS product_count,
|
||||||
AVG(sp.price_rec) AS avg_price,
|
AVG(sp.price_rec) AS avg_price,
|
||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
JOIN states s ON s.id = sp.state_id
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
JOIN states s ON s.id = d.state_id
|
||||||
WHERE s.medical_legal = TRUE
|
WHERE s.medical_legal = TRUE
|
||||||
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
AND (s.recreational_legal = FALSE OR s.recreational_legal IS NULL)
|
||||||
AND sp.price_rec IS NOT NULL
|
AND sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
${categoryFilter}
|
${categoryFilter}
|
||||||
GROUP BY sp.category
|
GROUP BY sp.category_raw
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(r.category, m.category) AS category,
|
COALESCE(r.category, m.category) AS category,
|
||||||
@@ -357,7 +362,7 @@ export class StateAnalyticsService {
|
|||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
recreational: {
|
recreational: {
|
||||||
state_count: parseInt(row.rec_state_count) || 0,
|
state_count: parseInt(row.rec_state_count) || 0,
|
||||||
product_count: parseInt(row.rec_product_count) || 0,
|
product_count: parseInt(row.rec_product_count) || 0,
|
||||||
@@ -395,12 +400,12 @@ export class StateAnalyticsService {
|
|||||||
COALESCE(s.medical_legal, FALSE) AS medical_legal,
|
COALESCE(s.medical_legal, FALSE) AS medical_legal,
|
||||||
COUNT(DISTINCT d.id) AS dispensary_count,
|
COUNT(DISTINCT d.id) AS dispensary_count,
|
||||||
COUNT(DISTINCT sp.id) AS product_count,
|
COUNT(DISTINCT sp.id) AS product_count,
|
||||||
COUNT(DISTINCT sp.brand_name) FILTER (WHERE sp.brand_name IS NOT NULL) AS brand_count,
|
COUNT(DISTINCT sp.brand_name_raw) FILTER (WHERE sp.brand_name_raw IS NOT NULL) AS brand_count,
|
||||||
MAX(sps.captured_at) AS last_crawl_at
|
MAX(sps.captured_at) AS last_crawl_at
|
||||||
FROM states s
|
FROM states s
|
||||||
LEFT JOIN dispensaries d ON d.state_id = s.id
|
LEFT JOIN dispensaries d ON d.state_id = s.id
|
||||||
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE
|
LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
|
||||||
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id
|
LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
|
||||||
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
||||||
ORDER BY dispensary_count DESC, s.name
|
ORDER BY dispensary_count DESC, s.name
|
||||||
`);
|
`);
|
||||||
@@ -451,8 +456,8 @@ export class StateAnalyticsService {
|
|||||||
END AS gap_reason
|
END AS gap_reason
|
||||||
FROM states s
|
FROM states s
|
||||||
LEFT JOIN dispensaries d ON d.state_id = s.id
|
LEFT JOIN dispensaries d ON d.state_id = s.id
|
||||||
LEFT JOIN store_products sp ON sp.state_id = s.id AND sp.is_in_stock = TRUE
|
LEFT JOIN store_products sp ON sp.dispensary_id = d.id AND sp.is_in_stock = TRUE
|
||||||
LEFT JOIN store_product_snapshots sps ON sps.state_id = s.id
|
LEFT JOIN store_product_snapshots sps ON sps.dispensary_id = d.id
|
||||||
WHERE s.recreational_legal = TRUE OR s.medical_legal = TRUE
|
WHERE s.recreational_legal = TRUE OR s.medical_legal = TRUE
|
||||||
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
GROUP BY s.code, s.name, s.recreational_legal, s.medical_legal
|
||||||
HAVING COUNT(DISTINCT d.id) = 0
|
HAVING COUNT(DISTINCT d.id) = 0
|
||||||
@@ -499,7 +504,8 @@ export class StateAnalyticsService {
|
|||||||
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price,
|
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY sp.price_rec) AS median_price,
|
||||||
COUNT(*) AS product_count
|
COUNT(*) AS product_count
|
||||||
FROM states s
|
FROM states s
|
||||||
JOIN store_products sp ON sp.state_id = s.id
|
JOIN dispensaries d ON d.state_id = s.id
|
||||||
|
JOIN store_products sp ON sp.dispensary_id = d.id
|
||||||
WHERE sp.price_rec IS NOT NULL
|
WHERE sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
AND (s.recreational_legal = TRUE OR s.medical_legal = TRUE)
|
||||||
|
|||||||
@@ -89,22 +89,22 @@ export class StoreAnalyticsService {
|
|||||||
// Get brands added/dropped
|
// Get brands added/dropped
|
||||||
const brandsResult = await this.pool.query(`
|
const brandsResult = await this.pool.query(`
|
||||||
WITH start_brands AS (
|
WITH start_brands AS (
|
||||||
SELECT DISTINCT brand_name
|
SELECT DISTINCT brand_name_raw
|
||||||
FROM store_product_snapshots
|
FROM store_product_snapshots
|
||||||
WHERE dispensary_id = $1
|
WHERE dispensary_id = $1
|
||||||
AND captured_at >= $2 AND captured_at < $2 + INTERVAL '1 day'
|
AND captured_at >= $2::timestamp AND captured_at < $2::timestamp + INTERVAL '1 day'
|
||||||
AND brand_name IS NOT NULL
|
AND brand_name_raw IS NOT NULL
|
||||||
),
|
),
|
||||||
end_brands AS (
|
end_brands AS (
|
||||||
SELECT DISTINCT brand_name
|
SELECT DISTINCT brand_name_raw
|
||||||
FROM store_product_snapshots
|
FROM store_product_snapshots
|
||||||
WHERE dispensary_id = $1
|
WHERE dispensary_id = $1
|
||||||
AND captured_at >= $3 - INTERVAL '1 day' AND captured_at <= $3
|
AND captured_at >= $3::timestamp - INTERVAL '1 day' AND captured_at <= $3::timestamp
|
||||||
AND brand_name IS NOT NULL
|
AND brand_name_raw IS NOT NULL
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
ARRAY(SELECT brand_name FROM end_brands EXCEPT SELECT brand_name FROM start_brands) AS added,
|
ARRAY(SELECT brand_name_raw FROM end_brands EXCEPT SELECT brand_name_raw FROM start_brands) AS added,
|
||||||
ARRAY(SELECT brand_name FROM start_brands EXCEPT SELECT brand_name FROM end_brands) AS dropped
|
ARRAY(SELECT brand_name_raw FROM start_brands EXCEPT SELECT brand_name_raw FROM end_brands) AS dropped
|
||||||
`, [dispensaryId, start, end]);
|
`, [dispensaryId, start, end]);
|
||||||
|
|
||||||
const brands = brandsResult.rows[0] || { added: [], dropped: [] };
|
const brands = brandsResult.rows[0] || { added: [], dropped: [] };
|
||||||
@@ -184,9 +184,9 @@ export class StoreAnalyticsService {
|
|||||||
-- Products added
|
-- Products added
|
||||||
SELECT
|
SELECT
|
||||||
sp.id AS store_product_id,
|
sp.id AS store_product_id,
|
||||||
sp.name AS product_name,
|
sp.name_raw AS product_name,
|
||||||
sp.brand_name,
|
sp.brand_name_raw,
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
'added' AS event_type,
|
'added' AS event_type,
|
||||||
sp.first_seen_at AS event_date,
|
sp.first_seen_at AS event_date,
|
||||||
NULL::TEXT AS old_value,
|
NULL::TEXT AS old_value,
|
||||||
@@ -201,9 +201,9 @@ export class StoreAnalyticsService {
|
|||||||
-- Stock in/out from snapshots
|
-- Stock in/out from snapshots
|
||||||
SELECT
|
SELECT
|
||||||
sps.store_product_id,
|
sps.store_product_id,
|
||||||
sp.name AS product_name,
|
sp.name_raw AS product_name,
|
||||||
sp.brand_name,
|
sp.brand_name_raw,
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
CASE
|
CASE
|
||||||
WHEN sps.is_in_stock = TRUE AND LAG(sps.is_in_stock) OVER w = FALSE THEN 'stock_in'
|
WHEN sps.is_in_stock = TRUE AND LAG(sps.is_in_stock) OVER w = FALSE THEN 'stock_in'
|
||||||
WHEN sps.is_in_stock = FALSE AND LAG(sps.is_in_stock) OVER w = TRUE THEN 'stock_out'
|
WHEN sps.is_in_stock = FALSE AND LAG(sps.is_in_stock) OVER w = TRUE THEN 'stock_out'
|
||||||
@@ -224,9 +224,9 @@ export class StoreAnalyticsService {
|
|||||||
-- Price changes from snapshots
|
-- Price changes from snapshots
|
||||||
SELECT
|
SELECT
|
||||||
sps.store_product_id,
|
sps.store_product_id,
|
||||||
sp.name AS product_name,
|
sp.name_raw AS product_name,
|
||||||
sp.brand_name,
|
sp.brand_name_raw,
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
'price_change' AS event_type,
|
'price_change' AS event_type,
|
||||||
sps.captured_at AS event_date,
|
sps.captured_at AS event_date,
|
||||||
LAG(sps.price_rec::TEXT) OVER w AS old_value,
|
LAG(sps.price_rec::TEXT) OVER w AS old_value,
|
||||||
@@ -250,8 +250,8 @@ export class StoreAnalyticsService {
|
|||||||
return result.rows.map((row: any) => ({
|
return result.rows.map((row: any) => ({
|
||||||
store_product_id: row.store_product_id,
|
store_product_id: row.store_product_id,
|
||||||
product_name: row.product_name,
|
product_name: row.product_name,
|
||||||
brand_name: row.brand_name,
|
brand_name: row.brand_name_raw,
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
event_type: row.event_type,
|
event_type: row.event_type,
|
||||||
event_date: row.event_date ? row.event_date.toISOString() : null,
|
event_date: row.event_date ? row.event_date.toISOString() : null,
|
||||||
old_value: row.old_value,
|
old_value: row.old_value,
|
||||||
@@ -259,6 +259,122 @@ export class StoreAnalyticsService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get quantity changes for a store (increases/decreases)
|
||||||
|
* Useful for estimating sales (decreases) or restocks (increases)
|
||||||
|
*
|
||||||
|
* @param direction - 'decrease' for likely sales, 'increase' for restocks, 'all' for both
|
||||||
|
*/
|
||||||
|
async getQuantityChanges(
|
||||||
|
dispensaryId: number,
|
||||||
|
options: {
|
||||||
|
window?: TimeWindow;
|
||||||
|
customRange?: DateRange;
|
||||||
|
direction?: 'increase' | 'decrease' | 'all';
|
||||||
|
limit?: number;
|
||||||
|
} = {}
|
||||||
|
): Promise<{
|
||||||
|
dispensary_id: number;
|
||||||
|
window: TimeWindow;
|
||||||
|
direction: string;
|
||||||
|
total_changes: number;
|
||||||
|
total_units_decreased: number;
|
||||||
|
total_units_increased: number;
|
||||||
|
changes: Array<{
|
||||||
|
store_product_id: number;
|
||||||
|
product_name: string;
|
||||||
|
brand_name: string | null;
|
||||||
|
category: string | null;
|
||||||
|
old_quantity: number;
|
||||||
|
new_quantity: number;
|
||||||
|
quantity_delta: number;
|
||||||
|
direction: 'increase' | 'decrease';
|
||||||
|
captured_at: string;
|
||||||
|
}>;
|
||||||
|
}> {
|
||||||
|
const { window = '7d', customRange, direction = 'all', limit = 100 } = options;
|
||||||
|
const { start, end } = getDateRangeFromWindow(window, customRange);
|
||||||
|
|
||||||
|
// Build direction filter
|
||||||
|
let directionFilter = '';
|
||||||
|
if (direction === 'decrease') {
|
||||||
|
directionFilter = 'AND qty_delta < 0';
|
||||||
|
} else if (direction === 'increase') {
|
||||||
|
directionFilter = 'AND qty_delta > 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.pool.query(`
|
||||||
|
WITH qty_changes AS (
|
||||||
|
SELECT
|
||||||
|
sps.store_product_id,
|
||||||
|
sp.name_raw AS product_name,
|
||||||
|
sp.brand_name_raw AS brand_name,
|
||||||
|
sp.category_raw AS category,
|
||||||
|
LAG(sps.stock_quantity) OVER w AS old_quantity,
|
||||||
|
sps.stock_quantity AS new_quantity,
|
||||||
|
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta,
|
||||||
|
sps.captured_at
|
||||||
|
FROM store_product_snapshots sps
|
||||||
|
JOIN store_products sp ON sp.id = sps.store_product_id
|
||||||
|
WHERE sps.dispensary_id = $1
|
||||||
|
AND sps.captured_at >= $2
|
||||||
|
AND sps.captured_at <= $3
|
||||||
|
AND sps.stock_quantity IS NOT NULL
|
||||||
|
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM qty_changes
|
||||||
|
WHERE old_quantity IS NOT NULL
|
||||||
|
AND qty_delta != 0
|
||||||
|
${directionFilter}
|
||||||
|
ORDER BY captured_at DESC
|
||||||
|
LIMIT $4
|
||||||
|
`, [dispensaryId, start, end, limit]);
|
||||||
|
|
||||||
|
// Calculate totals
|
||||||
|
const totalsResult = await this.pool.query(`
|
||||||
|
WITH qty_changes AS (
|
||||||
|
SELECT
|
||||||
|
sps.stock_quantity - LAG(sps.stock_quantity) OVER w AS qty_delta
|
||||||
|
FROM store_product_snapshots sps
|
||||||
|
WHERE sps.dispensary_id = $1
|
||||||
|
AND sps.captured_at >= $2
|
||||||
|
AND sps.captured_at <= $3
|
||||||
|
AND sps.stock_quantity IS NOT NULL
|
||||||
|
AND sps.store_product_id IS NOT NULL
|
||||||
|
WINDOW w AS (PARTITION BY sps.store_product_id ORDER BY sps.captured_at)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) FILTER (WHERE qty_delta != 0) AS total_changes,
|
||||||
|
COALESCE(SUM(ABS(qty_delta)) FILTER (WHERE qty_delta < 0), 0) AS units_decreased,
|
||||||
|
COALESCE(SUM(qty_delta) FILTER (WHERE qty_delta > 0), 0) AS units_increased
|
||||||
|
FROM qty_changes
|
||||||
|
WHERE qty_delta IS NOT NULL
|
||||||
|
`, [dispensaryId, start, end]);
|
||||||
|
|
||||||
|
const totals = totalsResult.rows[0] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
dispensary_id: dispensaryId,
|
||||||
|
window,
|
||||||
|
direction,
|
||||||
|
total_changes: parseInt(totals.total_changes) || 0,
|
||||||
|
total_units_decreased: parseInt(totals.units_decreased) || 0,
|
||||||
|
total_units_increased: parseInt(totals.units_increased) || 0,
|
||||||
|
changes: result.rows.map((row: any) => ({
|
||||||
|
store_product_id: row.store_product_id,
|
||||||
|
product_name: row.product_name,
|
||||||
|
brand_name: row.brand_name_raw,
|
||||||
|
category: row.category_raw,
|
||||||
|
old_quantity: row.old_quantity,
|
||||||
|
new_quantity: row.new_quantity,
|
||||||
|
quantity_delta: row.qty_delta,
|
||||||
|
direction: row.qty_delta > 0 ? 'increase' : 'decrease',
|
||||||
|
captured_at: row.captured_at?.toISOString() || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get store inventory composition (categories and brands breakdown)
|
* Get store inventory composition (categories and brands breakdown)
|
||||||
*/
|
*/
|
||||||
@@ -299,14 +415,14 @@ export class StoreAnalyticsService {
|
|||||||
// Get top brands
|
// Get top brands
|
||||||
const brandsResult = await this.pool.query(`
|
const brandsResult = await this.pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
brand_name AS brand,
|
brand_name_raw AS brand,
|
||||||
COUNT(*) AS count,
|
COUNT(*) AS count,
|
||||||
ROUND(COUNT(*)::NUMERIC * 100 / NULLIF($2, 0), 2) AS percent
|
ROUND(COUNT(*)::NUMERIC * 100 / NULLIF($2, 0), 2) AS percent
|
||||||
FROM store_products
|
FROM store_products
|
||||||
WHERE dispensary_id = $1
|
WHERE dispensary_id = $1
|
||||||
AND brand_name IS NOT NULL
|
AND brand_name_raw IS NOT NULL
|
||||||
AND is_in_stock = TRUE
|
AND is_in_stock = TRUE
|
||||||
GROUP BY brand_name
|
GROUP BY brand_name_raw
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT 20
|
LIMIT 20
|
||||||
`, [dispensaryId, totalProducts]);
|
`, [dispensaryId, totalProducts]);
|
||||||
@@ -316,7 +432,7 @@ export class StoreAnalyticsService {
|
|||||||
in_stock_count: parseInt(totals.in_stock) || 0,
|
in_stock_count: parseInt(totals.in_stock) || 0,
|
||||||
out_of_stock_count: parseInt(totals.out_of_stock) || 0,
|
out_of_stock_count: parseInt(totals.out_of_stock) || 0,
|
||||||
categories: categoriesResult.rows.map((row: any) => ({
|
categories: categoriesResult.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
count: parseInt(row.count),
|
count: parseInt(row.count),
|
||||||
percent: parseFloat(row.percent) || 0,
|
percent: parseFloat(row.percent) || 0,
|
||||||
})),
|
})),
|
||||||
@@ -458,23 +574,24 @@ export class StoreAnalyticsService {
|
|||||||
),
|
),
|
||||||
market_prices AS (
|
market_prices AS (
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
AVG(sp.price_rec) AS market_avg
|
AVG(sp.price_rec) AS market_avg
|
||||||
FROM store_products sp
|
FROM store_products sp
|
||||||
WHERE sp.state_id = $2
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
WHERE d.state_id = $2
|
||||||
AND sp.price_rec IS NOT NULL
|
AND sp.price_rec IS NOT NULL
|
||||||
AND sp.is_in_stock = TRUE
|
AND sp.is_in_stock = TRUE
|
||||||
AND sp.category IS NOT NULL
|
AND sp.category_raw IS NOT NULL
|
||||||
GROUP BY sp.category
|
GROUP BY sp.category_raw
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
sp.category,
|
sp.category_raw,
|
||||||
sp.store_avg AS store_avg_price,
|
sp.store_avg AS store_avg_price,
|
||||||
mp.market_avg AS market_avg_price,
|
mp.market_avg AS market_avg_price,
|
||||||
ROUND(((sp.store_avg - mp.market_avg) / NULLIF(mp.market_avg, 0) * 100)::NUMERIC, 2) AS price_vs_market_percent,
|
ROUND(((sp.store_avg - mp.market_avg) / NULLIF(mp.market_avg, 0) * 100)::NUMERIC, 2) AS price_vs_market_percent,
|
||||||
sp.product_count
|
sp.product_count
|
||||||
FROM store_prices sp
|
FROM store_prices sp
|
||||||
LEFT JOIN market_prices mp ON mp.category = sp.category
|
LEFT JOIN market_prices mp ON mp.category = sp.category_raw
|
||||||
ORDER BY sp.product_count DESC
|
ORDER BY sp.product_count DESC
|
||||||
`, [dispensaryId, dispensary.state_id]);
|
`, [dispensaryId, dispensary.state_id]);
|
||||||
|
|
||||||
@@ -486,9 +603,10 @@ export class StoreAnalyticsService {
|
|||||||
WHERE dispensary_id = $1 AND price_rec IS NOT NULL AND is_in_stock = TRUE
|
WHERE dispensary_id = $1 AND price_rec IS NOT NULL AND is_in_stock = TRUE
|
||||||
),
|
),
|
||||||
market_avg AS (
|
market_avg AS (
|
||||||
SELECT AVG(price_rec) AS avg
|
SELECT AVG(sp.price_rec) AS avg
|
||||||
FROM store_products
|
FROM store_products sp
|
||||||
WHERE state_id = $2 AND price_rec IS NOT NULL AND is_in_stock = TRUE
|
JOIN dispensaries d ON d.id = sp.dispensary_id
|
||||||
|
WHERE d.state_id = $2 AND sp.price_rec IS NOT NULL AND sp.is_in_stock = TRUE
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
ROUND(((sa.avg - ma.avg) / NULLIF(ma.avg, 0) * 100)::NUMERIC, 2) AS price_vs_market
|
ROUND(((sa.avg - ma.avg) / NULLIF(ma.avg, 0) * 100)::NUMERIC, 2) AS price_vs_market
|
||||||
@@ -499,7 +617,7 @@ export class StoreAnalyticsService {
|
|||||||
dispensary_id: dispensaryId,
|
dispensary_id: dispensaryId,
|
||||||
dispensary_name: dispensary.name,
|
dispensary_name: dispensary.name,
|
||||||
categories: result.rows.map((row: any) => ({
|
categories: result.rows.map((row: any) => ({
|
||||||
category: row.category,
|
category: row.category_raw,
|
||||||
store_avg_price: parseFloat(row.store_avg_price),
|
store_avg_price: parseFloat(row.store_avg_price),
|
||||||
market_avg_price: row.market_avg_price ? parseFloat(row.market_avg_price) : 0,
|
market_avg_price: row.market_avg_price ? parseFloat(row.market_avg_price) : 0,
|
||||||
price_vs_market_percent: row.price_vs_market_percent ? parseFloat(row.price_vs_market_percent) : 0,
|
price_vs_market_percent: row.price_vs_market_percent ? parseFloat(row.price_vs_market_percent) : 0,
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ export { BrandPenetrationService } from './BrandPenetrationService';
|
|||||||
export { CategoryAnalyticsService } from './CategoryAnalyticsService';
|
export { CategoryAnalyticsService } from './CategoryAnalyticsService';
|
||||||
export { StoreAnalyticsService } from './StoreAnalyticsService';
|
export { StoreAnalyticsService } from './StoreAnalyticsService';
|
||||||
export { StateAnalyticsService } from './StateAnalyticsService';
|
export { StateAnalyticsService } from './StateAnalyticsService';
|
||||||
|
export { BrandIntelligenceService } from './BrandIntelligenceService';
|
||||||
|
|||||||
@@ -322,3 +322,48 @@ export interface RecVsMedPriceComparison {
|
|||||||
};
|
};
|
||||||
price_diff_percent: number | null;
|
price_diff_percent: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BRAND PROMOTIONAL ANALYTICS TYPES
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface BrandPromotionalEvent {
|
||||||
|
product_name: string;
|
||||||
|
store_product_id: number;
|
||||||
|
dispensary_id: number;
|
||||||
|
dispensary_name: string;
|
||||||
|
state_code: string;
|
||||||
|
category: string | null;
|
||||||
|
special_start: string; // ISO date when special started
|
||||||
|
special_end: string | null; // ISO date when special ended (null if ongoing)
|
||||||
|
duration_days: number | null;
|
||||||
|
regular_price: number;
|
||||||
|
special_price: number;
|
||||||
|
discount_percent: number;
|
||||||
|
quantity_at_start: number | null;
|
||||||
|
quantity_at_end: number | null;
|
||||||
|
quantity_sold_estimate: number | null; // quantity_at_start - quantity_at_end
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandPromotionalSummary {
|
||||||
|
brand_name: string;
|
||||||
|
window: TimeWindow;
|
||||||
|
total_promotional_events: number;
|
||||||
|
total_products_on_special: number;
|
||||||
|
total_dispensaries_with_specials: number;
|
||||||
|
states_with_specials: string[];
|
||||||
|
avg_discount_percent: number;
|
||||||
|
avg_duration_days: number | null;
|
||||||
|
total_quantity_sold_estimate: number | null;
|
||||||
|
promotional_frequency: {
|
||||||
|
weekly_avg: number;
|
||||||
|
monthly_avg: number;
|
||||||
|
};
|
||||||
|
by_category: Array<{
|
||||||
|
category: string;
|
||||||
|
event_count: number;
|
||||||
|
avg_discount_percent: number;
|
||||||
|
quantity_sold_estimate: number | null;
|
||||||
|
}>;
|
||||||
|
events: BrandPromotionalEvent[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ export async function cleanupOrphanedJobs(): Promise<void> {
|
|||||||
|
|
||||||
export type ProxyTestMode = 'all' | 'failed' | 'inactive';
|
export type ProxyTestMode = 'all' | 'failed' | 'inactive';
|
||||||
|
|
||||||
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<number> {
|
export interface CreateJobResult {
|
||||||
|
jobId: number;
|
||||||
|
totalProxies: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrency: number = DEFAULT_CONCURRENCY): Promise<CreateJobResult> {
|
||||||
// Check for existing running jobs first
|
// Check for existing running jobs first
|
||||||
const existingJob = await getActiveProxyTestJob();
|
const existingJob = await getActiveProxyTestJob();
|
||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
@@ -79,7 +84,7 @@ export async function createProxyTestJob(mode: ProxyTestMode = 'all', concurrenc
|
|||||||
console.error(`❌ Proxy test job ${jobId} failed:`, err);
|
console.error(`❌ Proxy test job ${jobId} failed:`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return jobId;
|
return { jobId, totalProxies };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {
|
export async function getProxyTestJob(jobId: number): Promise<ProxyTestJob | null> {
|
||||||
|
|||||||
@@ -10,6 +10,17 @@
|
|||||||
|
|
||||||
import { pool } from '../db/pool';
|
import { pool } from '../db/pool';
|
||||||
|
|
||||||
|
// Helper to check if a table exists
|
||||||
|
async function tableExists(tableName: string): Promise<boolean> {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_name = $1
|
||||||
|
) as exists
|
||||||
|
`, [tableName]);
|
||||||
|
return result.rows[0].exists;
|
||||||
|
}
|
||||||
|
|
||||||
export type TaskRole =
|
export type TaskRole =
|
||||||
| 'store_discovery'
|
| 'store_discovery'
|
||||||
| 'entry_point_discovery'
|
| 'entry_point_discovery'
|
||||||
@@ -270,6 +281,11 @@ class TaskService {
|
|||||||
* List tasks with filters
|
* List tasks with filters
|
||||||
*/
|
*/
|
||||||
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
|
async listTasks(filter: TaskFilter = {}): Promise<WorkerTask[]> {
|
||||||
|
// Return empty list if table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const params: (string | number | string[])[] = [];
|
const params: (string | number | string[])[] = [];
|
||||||
let paramIndex = 1;
|
let paramIndex = 1;
|
||||||
@@ -323,21 +339,41 @@ class TaskService {
|
|||||||
* Get capacity metrics for all roles
|
* Get capacity metrics for all roles
|
||||||
*/
|
*/
|
||||||
async getCapacityMetrics(): Promise<CapacityMetrics[]> {
|
async getCapacityMetrics(): Promise<CapacityMetrics[]> {
|
||||||
|
// Return empty metrics if worker_tasks table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM v_worker_capacity`
|
`SELECT * FROM v_worker_capacity`
|
||||||
);
|
);
|
||||||
return result.rows as CapacityMetrics[];
|
return result.rows as CapacityMetrics[];
|
||||||
|
} catch {
|
||||||
|
// View may not exist
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get capacity metrics for a specific role
|
* Get capacity metrics for a specific role
|
||||||
*/
|
*/
|
||||||
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
|
async getRoleCapacity(role: TaskRole): Promise<CapacityMetrics | null> {
|
||||||
|
// Return null if worker_tasks table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
`SELECT * FROM v_worker_capacity WHERE role = $1`,
|
||||||
[role]
|
[role]
|
||||||
);
|
);
|
||||||
return (result.rows[0] as CapacityMetrics) || null;
|
return (result.rows[0] as CapacityMetrics) || null;
|
||||||
|
} catch {
|
||||||
|
// View may not exist
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -463,12 +499,6 @@ class TaskService {
|
|||||||
* Get task counts by status for dashboard
|
* Get task counts by status for dashboard
|
||||||
*/
|
*/
|
||||||
async getTaskCounts(): Promise<Record<TaskStatus, number>> {
|
async getTaskCounts(): Promise<Record<TaskStatus, number>> {
|
||||||
const result = await pool.query(
|
|
||||||
`SELECT status, COUNT(*) as count
|
|
||||||
FROM worker_tasks
|
|
||||||
GROUP BY status`
|
|
||||||
);
|
|
||||||
|
|
||||||
const counts: Record<TaskStatus, number> = {
|
const counts: Record<TaskStatus, number> = {
|
||||||
pending: 0,
|
pending: 0,
|
||||||
claimed: 0,
|
claimed: 0,
|
||||||
@@ -478,6 +508,17 @@ class TaskService {
|
|||||||
stale: 0,
|
stale: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Return empty counts if table doesn't exist
|
||||||
|
if (!await tableExists('worker_tasks')) {
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT status, COUNT(*) as count
|
||||||
|
FROM worker_tasks
|
||||||
|
GROUP BY status`
|
||||||
|
);
|
||||||
|
|
||||||
for (const row of result.rows) {
|
for (const row of result.rows) {
|
||||||
const typedRow = row as { status: TaskStatus; count: string };
|
const typedRow = row as { status: TaskStatus; count: string };
|
||||||
counts[typedRow.status] = parseInt(typedRow.count, 10);
|
counts[typedRow.status] = parseInt(typedRow.count, 10);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ WORKDIR /app
|
|||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies (npm install is more forgiving than npm ci)
|
||||||
RUN npm ci
|
RUN npm install
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async testAllProxies() {
|
async testAllProxies() {
|
||||||
return this.request<{ jobId: number; message: string }>('/api/proxies/test-all', {
|
return this.request<{ jobId: number; total: number; message: string }>('/api/proxies/test-all', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { Layout } from '../components/Layout';
|
import { Layout } from '../components/Layout';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
import { Toast } from '../components/Toast';
|
import { Toast } from '../components/Toast';
|
||||||
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown } from 'lucide-react';
|
import { Key, Plus, Copy, Check, X, Trash2, Power, PowerOff, Store, Globe, Shield, Clock, Eye, EyeOff, Search, ChevronDown, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
interface ApiPermission {
|
interface ApiPermission {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -161,6 +161,12 @@ export function ApiPermissions() {
|
|||||||
allowed_ips: '',
|
allowed_ips: '',
|
||||||
allowed_domains: '',
|
allowed_domains: '',
|
||||||
});
|
});
|
||||||
|
const [editingPermission, setEditingPermission] = useState<ApiPermission | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
user_name: '',
|
||||||
|
allowed_ips: '',
|
||||||
|
allowed_domains: '',
|
||||||
|
});
|
||||||
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
const [notification, setNotification] = useState<{ message: string; type: 'success' | 'error' | 'info' } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -240,6 +246,33 @@ export function ApiPermissions() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (perm: ApiPermission) => {
|
||||||
|
setEditingPermission(perm);
|
||||||
|
setEditForm({
|
||||||
|
user_name: perm.user_name,
|
||||||
|
allowed_ips: perm.allowed_ips || '',
|
||||||
|
allowed_domains: perm.allowed_domains || '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editingPermission) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.updateApiPermission(editingPermission.id, {
|
||||||
|
user_name: editForm.user_name,
|
||||||
|
allowed_ips: editForm.allowed_ips || undefined,
|
||||||
|
allowed_domains: editForm.allowed_domains || undefined,
|
||||||
|
});
|
||||||
|
setNotification({ message: 'API key updated successfully', type: 'success' });
|
||||||
|
setEditingPermission(null);
|
||||||
|
loadPermissions();
|
||||||
|
} catch (error: any) {
|
||||||
|
setNotification({ message: 'Failed to update permission: ' + error.message, type: 'error' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, id: number) => {
|
const copyToClipboard = async (text: string, id: number) => {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
setCopiedId(id);
|
setCopiedId(id);
|
||||||
@@ -494,21 +527,36 @@ export function ApiPermissions() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Restrictions */}
|
{/* Allowed Domains - Always show */}
|
||||||
{(perm.allowed_ips || perm.allowed_domains) && (
|
<div className="mt-3 text-xs">
|
||||||
<div className="flex gap-4 mt-3 text-xs text-gray-500">
|
<span className="text-gray-500 flex items-center gap-1">
|
||||||
{perm.allowed_ips && (
|
<Globe className="w-3 h-3" />
|
||||||
<span>IPs: {perm.allowed_ips.split('\n').length} allowed</span>
|
Domains:{' '}
|
||||||
|
{perm.allowed_domains ? (
|
||||||
|
<span className="text-gray-700 font-mono">
|
||||||
|
{perm.allowed_domains.split('\n').filter(d => d.trim()).join(', ')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-600">Any domain (no restriction)</span>
|
||||||
)}
|
)}
|
||||||
{perm.allowed_domains && (
|
</span>
|
||||||
<span>Domains: {perm.allowed_domains.split('\n').length} allowed</span>
|
{perm.allowed_ips && (
|
||||||
|
<span className="text-gray-500 ml-4">
|
||||||
|
IPs: {perm.allowed_ips.split('\n').filter(ip => ip.trim()).length} allowed
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex items-center gap-2 ml-4">
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(perm)}
|
||||||
|
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<Pencil className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggle(perm.id)}
|
onClick={() => handleToggle(perm.id)}
|
||||||
className={`p-2 rounded-lg transition-colors ${
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
@@ -534,6 +582,86 @@ export function ApiPermissions() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{editingPermission && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<Pencil className="w-5 h-5 text-blue-600" />
|
||||||
|
Edit API Key
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{editingPermission.store_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveEdit} className="p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Label / Website Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editForm.user_name}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, user_name: e.target.value })}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Globe className="w-4 h-4 inline mr-1" />
|
||||||
|
Allowed Domains
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editForm.allowed_domains}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowed_domains: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||||
|
placeholder="example.com *.example.com subdomain.example.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
One domain per line. Use * for wildcards (e.g., *.example.com). Leave empty to allow any domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Shield className="w-4 h-4 inline mr-1" />
|
||||||
|
Allowed IP Addresses
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editForm.allowed_ips}
|
||||||
|
onChange={(e) => setEditForm({ ...editForm, allowed_ips: e.target.value })}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
|
||||||
|
placeholder="192.168.1.1 10.0.0.0/8"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">One per line. CIDR notation supported. Leave empty to allow any IP.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex-1 px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingPermission(null)}
|
||||||
|
className="px-5 py-2.5 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,7 +96,8 @@ export function Proxies() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.testAllProxies();
|
const response = await api.testAllProxies();
|
||||||
setNotification({ message: 'Proxy testing job started', type: 'success' });
|
setNotification({ message: 'Proxy testing job started', type: 'success' });
|
||||||
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: proxies.length, passed_proxies: 0, failed_proxies: 0 });
|
// Use response.total if available, otherwise proxies.length, but immediately poll for accurate count
|
||||||
|
setActiveJob({ id: response.jobId, status: 'pending', tested_proxies: 0, total_proxies: response.total || proxies.length || 0, passed_proxies: 0, failed_proxies: 0 });
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
|
setNotification({ message: 'Failed to start testing: ' + error.message, type: 'error' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { api } from '../../../lib/api';
|
import { api } from '../../../lib/api';
|
||||||
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2 } from 'lucide-react';
|
import { Building2, Tag, Globe, Target, FileText, RefreshCw, Sparkles, Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
interface SeoPage {
|
interface SeoPage {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -47,11 +47,31 @@ export function PagesTab() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [generatingId, setGeneratingId] = useState<number | null>(null);
|
const [generatingId, setGeneratingId] = useState<number | null>(null);
|
||||||
|
const [hasActiveAiProvider, setHasActiveAiProvider] = useState<boolean | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPages();
|
loadPages();
|
||||||
|
checkAiProvider();
|
||||||
}, [typeFilter, search]);
|
}, [typeFilter, search]);
|
||||||
|
|
||||||
|
async function checkAiProvider() {
|
||||||
|
try {
|
||||||
|
const data = await api.getSettings();
|
||||||
|
const settings = data.settings || [];
|
||||||
|
// Check if either Anthropic or OpenAI is configured with an API key AND enabled
|
||||||
|
const anthropicKey = settings.find((s: any) => s.key === 'anthropic_api_key')?.value;
|
||||||
|
const anthropicEnabled = settings.find((s: any) => s.key === 'anthropic_enabled')?.value === 'true';
|
||||||
|
const openaiKey = settings.find((s: any) => s.key === 'openai_api_key')?.value;
|
||||||
|
const openaiEnabled = settings.find((s: any) => s.key === 'openai_enabled')?.value === 'true';
|
||||||
|
|
||||||
|
const hasProvider = (anthropicKey && anthropicEnabled) || (openaiKey && openaiEnabled);
|
||||||
|
setHasActiveAiProvider(!!hasProvider);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check AI provider:', error);
|
||||||
|
setHasActiveAiProvider(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPages() {
|
async function loadPages() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -188,12 +208,18 @@ export function PagesTab() {
|
|||||||
<td className="px-3 sm:px-4 py-3">
|
<td className="px-3 sm:px-4 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleGenerate(page.id)}
|
onClick={() => handleGenerate(page.id)}
|
||||||
disabled={generatingId === page.id}
|
disabled={generatingId === page.id || hasActiveAiProvider === false}
|
||||||
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"
|
className={`flex items-center gap-1 px-2 sm:px-3 py-1.5 text-xs font-medium rounded-lg disabled:cursor-not-allowed ${
|
||||||
title="Generate content"
|
hasActiveAiProvider === false
|
||||||
|
? 'bg-gray-100 text-gray-400'
|
||||||
|
: 'bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50'
|
||||||
|
}`}
|
||||||
|
title={hasActiveAiProvider === false ? 'No Active AI Provider' : 'Generate content'}
|
||||||
>
|
>
|
||||||
{generatingId === page.id ? (
|
{generatingId === page.id ? (
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : hasActiveAiProvider === false ? (
|
||||||
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
) : (
|
) : (
|
||||||
<Sparkles className="w-3.5 h-3.5" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,16 +7,6 @@
|
|||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo192.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
|
|||||||
@@ -373,10 +373,12 @@ export function mapCategoryForUI(apiCategory) {
|
|||||||
* Map API brand to UI-compatible format
|
* Map API brand to UI-compatible format
|
||||||
*/
|
*/
|
||||||
export function mapBrandForUI(apiBrand) {
|
export function mapBrandForUI(apiBrand) {
|
||||||
|
// API returns 'brand' field (see /api/v1/brands endpoint)
|
||||||
|
const brandName = apiBrand.brand || apiBrand.brand_name || '';
|
||||||
return {
|
return {
|
||||||
id: apiBrand.brand_name,
|
id: brandName,
|
||||||
name: apiBrand.brand_name,
|
name: brandName,
|
||||||
slug: apiBrand.brand_name?.toLowerCase().replace(/\s+/g, '-'),
|
slug: brandName ? brandName.toLowerCase().replace(/\s+/g, '-') : '',
|
||||||
logo: apiBrand.brand_logo_url || null,
|
logo: apiBrand.brand_logo_url || null,
|
||||||
productCount: parseInt(apiBrand.product_count || 0, 10),
|
productCount: parseInt(apiBrand.product_count || 0, 10),
|
||||||
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
|
dispensaryCount: parseInt(apiBrand.dispensary_count || 0, 10),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Brands = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredBrands = brands.filter((brand) =>
|
const filteredBrands = brands.filter((brand) =>
|
||||||
brand.name.toLowerCase().includes(searchQuery.toLowerCase())
|
brand.name && brand.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group brands alphabetically
|
// Group brands alphabetically
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
1.5.4
|
1.6.0
|
||||||
|
|||||||
@@ -312,3 +312,184 @@
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border-left: 4px solid #c62828;
|
border-left: 4px solid #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Brand Grid Widget
|
||||||
|
======================================== */
|
||||||
|
.cannaiq-brand-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-brand-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Category List Widget
|
||||||
|
======================================== */
|
||||||
|
.cannaiq-category-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: background 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-item:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-pills-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-pills-item:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-category-count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
Specials/Deals Grid Widget
|
||||||
|
======================================== */
|
||||||
|
.cannaiq-specials-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-discount-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-price .cannaiq-price-sale {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cannaiq-special-price .cannaiq-price-regular {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: CannaIQ Menus
|
* Plugin Name: CannaIQ Menus
|
||||||
* Plugin URI: https://cannaiq.co
|
* Plugin URI: https://cannaiq.co
|
||||||
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily.
|
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily.
|
||||||
* Version: 1.5.4
|
* Version: 1.6.0
|
||||||
* Author: CannaIQ
|
* Author: CannaIQ
|
||||||
* Author URI: https://cannaiq.co
|
* Author URI: https://cannaiq.co
|
||||||
* License: GPL v2 or later
|
* License: GPL v2 or later
|
||||||
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
|
|||||||
exit; // Exit if accessed directly
|
exit; // Exit if accessed directly
|
||||||
}
|
}
|
||||||
|
|
||||||
define('CANNAIQ_MENUS_VERSION', '1.5.4');
|
define('CANNAIQ_MENUS_VERSION', '1.6.0');
|
||||||
define('CANNAIQ_MENUS_API_URL', 'https://cannaiq.co/api/v1');
|
define('CANNAIQ_MENUS_API_URL', 'https://cannaiq.co/api/v1');
|
||||||
define('CANNAIQ_MENUS_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
define('CANNAIQ_MENUS_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||||
define('CANNAIQ_MENUS_PLUGIN_URL', plugin_dir_url(__FILE__));
|
define('CANNAIQ_MENUS_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||||
@@ -46,14 +46,17 @@ class CannaIQ_Menus_Plugin {
|
|||||||
// Initialize plugin
|
// Initialize plugin
|
||||||
load_plugin_textdomain('cannaiq-menus', false, dirname(plugin_basename(__FILE__)) . '/languages');
|
load_plugin_textdomain('cannaiq-menus', false, dirname(plugin_basename(__FILE__)) . '/languages');
|
||||||
|
|
||||||
// Register shortcodes
|
// Register shortcodes - primary CannaIQ shortcodes
|
||||||
add_shortcode('cannaiq_products', [$this, 'products_shortcode']);
|
add_shortcode('cannaiq_products', [$this, 'products_shortcode']);
|
||||||
add_shortcode('cannaiq_product', [$this, 'single_product_shortcode']);
|
add_shortcode('cannaiq_product', [$this, 'single_product_shortcode']);
|
||||||
// Legacy shortcode support (backward compatibility)
|
|
||||||
add_shortcode('crawlsy_products', [$this, 'products_shortcode']);
|
// DEPRECATED: Legacy shortcode aliases for backward compatibility only
|
||||||
add_shortcode('crawlsy_product', [$this, 'single_product_shortcode']);
|
// These allow sites that used the old plugin names to continue working
|
||||||
add_shortcode('dutchie_products', [$this, 'products_shortcode']);
|
// New implementations should use [cannaiq_products] and [cannaiq_product]
|
||||||
add_shortcode('dutchie_product', [$this, 'single_product_shortcode']);
|
add_shortcode('crawlsy_products', [$this, 'products_shortcode']); // deprecated
|
||||||
|
add_shortcode('crawlsy_product', [$this, 'single_product_shortcode']); // deprecated
|
||||||
|
add_shortcode('dutchie_products', [$this, 'products_shortcode']); // deprecated
|
||||||
|
add_shortcode('dutchie_product', [$this, 'single_product_shortcode']); // deprecated
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,9 +65,15 @@ class CannaIQ_Menus_Plugin {
|
|||||||
public function register_elementor_widgets($widgets_manager) {
|
public function register_elementor_widgets($widgets_manager) {
|
||||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-grid.php';
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-grid.php';
|
||||||
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/single-product.php';
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/single-product.php';
|
||||||
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/brand-grid.php';
|
||||||
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/category-list.php';
|
||||||
|
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/specials-grid.php';
|
||||||
|
|
||||||
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
|
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
|
||||||
$widgets_manager->register(new \CannaIQ_Menus_Single_Product_Widget());
|
$widgets_manager->register(new \CannaIQ_Menus_Single_Product_Widget());
|
||||||
|
$widgets_manager->register(new \CannaIQ_Menus_Brand_Grid_Widget());
|
||||||
|
$widgets_manager->register(new \CannaIQ_Menus_Category_List_Widget());
|
||||||
|
$widgets_manager->register(new \CannaIQ_Menus_Specials_Grid_Widget());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,7 +117,9 @@ class CannaIQ_Menus_Plugin {
|
|||||||
public function register_settings() {
|
public function register_settings() {
|
||||||
register_setting('cannaiq_menus_settings', 'cannaiq_api_token');
|
register_setting('cannaiq_menus_settings', 'cannaiq_api_token');
|
||||||
|
|
||||||
// Migrate old settings if they exist
|
// MIGRATION: Auto-migrate API tokens from old plugin versions
|
||||||
|
// This runs once - if user had crawlsy or dutchie plugin, their token is preserved
|
||||||
|
// Can be removed in a future major version once all users have migrated
|
||||||
$old_crawlsy_token = get_option('crawlsy_api_token');
|
$old_crawlsy_token = get_option('crawlsy_api_token');
|
||||||
$old_dutchie_token = get_option('dutchie_api_token');
|
$old_dutchie_token = get_option('dutchie_api_token');
|
||||||
|
|
||||||
@@ -392,6 +403,152 @@ class CannaIQ_Menus_Plugin {
|
|||||||
|
|
||||||
return $data['product'] ?? false;
|
return $data['product'] ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Categories from API
|
||||||
|
*/
|
||||||
|
public function fetch_categories($args = []) {
|
||||||
|
$api_token = get_option('cannaiq_api_token');
|
||||||
|
|
||||||
|
if (!$api_token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query_args = http_build_query($args);
|
||||||
|
$url = CANNAIQ_MENUS_API_URL . '/categories' . ($query_args ? '?' . $query_args : '');
|
||||||
|
|
||||||
|
$response = wp_remote_get($url, [
|
||||||
|
'headers' => [
|
||||||
|
'X-API-Key' => $api_token
|
||||||
|
],
|
||||||
|
'timeout' => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
return $data['categories'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Brands from API
|
||||||
|
*/
|
||||||
|
public function fetch_brands($args = []) {
|
||||||
|
$api_token = get_option('cannaiq_api_token');
|
||||||
|
|
||||||
|
if (!$api_token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query_args = http_build_query($args);
|
||||||
|
$url = CANNAIQ_MENUS_API_URL . '/brands' . ($query_args ? '?' . $query_args : '');
|
||||||
|
|
||||||
|
$response = wp_remote_get($url, [
|
||||||
|
'headers' => [
|
||||||
|
'X-API-Key' => $api_token
|
||||||
|
],
|
||||||
|
'timeout' => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
return $data['brands'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch Specials/Deals from API
|
||||||
|
*/
|
||||||
|
public function fetch_specials($args = []) {
|
||||||
|
$api_token = get_option('cannaiq_api_token');
|
||||||
|
|
||||||
|
if (!$api_token) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query_args = http_build_query($args);
|
||||||
|
$url = CANNAIQ_MENUS_API_URL . '/specials' . ($query_args ? '?' . $query_args : '');
|
||||||
|
|
||||||
|
$response = wp_remote_get($url, [
|
||||||
|
'headers' => [
|
||||||
|
'X-API-Key' => $api_token
|
||||||
|
],
|
||||||
|
'timeout' => 30
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
return $data['products'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get categories as options for Elementor select control
|
||||||
|
* Returns cached results for performance
|
||||||
|
*/
|
||||||
|
public function get_category_options() {
|
||||||
|
$cache_key = 'cannaiq_category_options';
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
|
||||||
|
if ($cached !== false) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = $this->fetch_categories();
|
||||||
|
$options = ['' => __('All Categories', 'cannaiq-menus')];
|
||||||
|
|
||||||
|
if ($categories) {
|
||||||
|
foreach ($categories as $cat) {
|
||||||
|
$name = $cat['type'] ?? $cat['name'] ?? '';
|
||||||
|
if ($name) {
|
||||||
|
$options[$name] = ucwords(str_replace('_', ' ', $name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get brands as options for Elementor select control
|
||||||
|
* Returns cached results for performance
|
||||||
|
*/
|
||||||
|
public function get_brand_options() {
|
||||||
|
$cache_key = 'cannaiq_brand_options';
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
|
||||||
|
if ($cached !== false) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$brands = $this->fetch_brands(['limit' => 200]);
|
||||||
|
$options = ['' => __('All Brands', 'cannaiq-menus')];
|
||||||
|
|
||||||
|
if ($brands) {
|
||||||
|
foreach ($brands as $brand) {
|
||||||
|
$name = $brand['brand'] ?? $brand['brand_name'] ?? '';
|
||||||
|
if ($name) {
|
||||||
|
$options[$name] = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($cache_key, $options, 5 * MINUTE_IN_SECONDS);
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Plugin
|
// Initialize Plugin
|
||||||
|
|||||||
184
wordpress-plugin/widgets/brand-grid.php
Normal file
184
wordpress-plugin/widgets/brand-grid.php
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Elementor Brand Grid Widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CannaIQ_Menus_Brand_Grid_Widget extends \Elementor\Widget_Base {
|
||||||
|
|
||||||
|
public function get_name() {
|
||||||
|
return 'cannaiq_brand_grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_title() {
|
||||||
|
return __('CannaIQ Brand Grid', 'cannaiq-menus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_icon() {
|
||||||
|
return 'eicon-gallery-grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_categories() {
|
||||||
|
return ['general'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function register_controls() {
|
||||||
|
|
||||||
|
// Content Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'content_section',
|
||||||
|
[
|
||||||
|
'label' => __('Content', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'limit',
|
||||||
|
[
|
||||||
|
'label' => __('Number of Brands', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||||
|
'default' => 12,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 100,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'columns',
|
||||||
|
[
|
||||||
|
'label' => __('Columns', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '4',
|
||||||
|
'options' => [
|
||||||
|
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||||
|
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||||
|
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||||
|
'6' => __('6 Columns', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_product_count',
|
||||||
|
[
|
||||||
|
'label' => __('Show Product Count', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'link_to_products',
|
||||||
|
[
|
||||||
|
'label' => __('Link to Products Page', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::URL,
|
||||||
|
'placeholder' => __('/products', 'cannaiq-menus'),
|
||||||
|
'description' => __('Brand name will be appended as ?brand=Name', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Style Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'style_section',
|
||||||
|
[
|
||||||
|
'label' => __('Style', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_background',
|
||||||
|
[
|
||||||
|
'label' => __('Card Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ffffff',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-brand-card' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_border_radius',
|
||||||
|
[
|
||||||
|
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||||
|
'size_units' => ['px'],
|
||||||
|
'range' => [
|
||||||
|
'px' => [
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'default' => [
|
||||||
|
'size' => 8,
|
||||||
|
],
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-brand-card' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'text_color',
|
||||||
|
[
|
||||||
|
'label' => __('Text Color', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#333333',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-brand-card' => 'color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render() {
|
||||||
|
$settings = $this->get_settings_for_display();
|
||||||
|
|
||||||
|
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||||
|
$brands = $plugin->fetch_brands(['limit' => $settings['limit']]);
|
||||||
|
|
||||||
|
if (!$brands) {
|
||||||
|
echo '<p>' . __('No brands found.', 'cannaiq-menus') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = $settings['columns'];
|
||||||
|
$link_base = $settings['link_to_products']['url'] ?? '';
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-brand-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
|
||||||
|
<?php foreach ($brands as $brand):
|
||||||
|
$brand_name = $brand['brand'] ?? $brand['brand_name'] ?? '';
|
||||||
|
$product_count = $brand['product_count'] ?? 0;
|
||||||
|
$brand_url = $link_base ? $link_base . '?brand=' . urlencode($brand_name) : '#';
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-brand-card"
|
||||||
|
<?php if ($brand_url !== '#'): ?>onclick="window.location.href='<?php echo esc_url($brand_url); ?>'"<?php endif; ?>
|
||||||
|
style="cursor: <?php echo ($brand_url !== '#') ? 'pointer' : 'default'; ?>;">
|
||||||
|
<div class="cannaiq-brand-content">
|
||||||
|
<h3 class="cannaiq-brand-name">
|
||||||
|
<?php echo esc_html($brand_name); ?>
|
||||||
|
</h3>
|
||||||
|
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
|
||||||
|
<span class="cannaiq-brand-count">
|
||||||
|
<?php echo esc_html($product_count); ?> <?php _e('products', 'cannaiq-menus'); ?>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
205
wordpress-plugin/widgets/category-list.php
Normal file
205
wordpress-plugin/widgets/category-list.php
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Elementor Category List Widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CannaIQ_Menus_Category_List_Widget extends \Elementor\Widget_Base {
|
||||||
|
|
||||||
|
public function get_name() {
|
||||||
|
return 'cannaiq_category_list';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_title() {
|
||||||
|
return __('CannaIQ Category List', 'cannaiq-menus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_icon() {
|
||||||
|
return 'eicon-bullet-list';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_categories() {
|
||||||
|
return ['general'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function register_controls() {
|
||||||
|
|
||||||
|
// Content Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'content_section',
|
||||||
|
[
|
||||||
|
'label' => __('Content', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'layout',
|
||||||
|
[
|
||||||
|
'label' => __('Layout', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => 'grid',
|
||||||
|
'options' => [
|
||||||
|
'grid' => __('Grid', 'cannaiq-menus'),
|
||||||
|
'list' => __('List', 'cannaiq-menus'),
|
||||||
|
'pills' => __('Pills/Tags', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'columns',
|
||||||
|
[
|
||||||
|
'label' => __('Columns', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '3',
|
||||||
|
'options' => [
|
||||||
|
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||||
|
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||||
|
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||||
|
'6' => __('6 Columns', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
'condition' => [
|
||||||
|
'layout' => 'grid',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_product_count',
|
||||||
|
[
|
||||||
|
'label' => __('Show Product Count', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'link_to_products',
|
||||||
|
[
|
||||||
|
'label' => __('Link to Products Page', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::URL,
|
||||||
|
'placeholder' => __('/products', 'cannaiq-menus'),
|
||||||
|
'description' => __('Category name will be appended as ?category=Name', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Style Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'style_section',
|
||||||
|
[
|
||||||
|
'label' => __('Style', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_background',
|
||||||
|
[
|
||||||
|
'label' => __('Card Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ffffff',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_border_radius',
|
||||||
|
[
|
||||||
|
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||||
|
'size_units' => ['px'],
|
||||||
|
'range' => [
|
||||||
|
'px' => [
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'default' => [
|
||||||
|
'size' => 8,
|
||||||
|
],
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'text_color',
|
||||||
|
[
|
||||||
|
'label' => __('Text Color', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#333333',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item' => 'color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'hover_background',
|
||||||
|
[
|
||||||
|
'label' => __('Hover Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#f3f4f6',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-category-item:hover' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render() {
|
||||||
|
$settings = $this->get_settings_for_display();
|
||||||
|
|
||||||
|
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||||
|
$categories = $plugin->fetch_categories();
|
||||||
|
|
||||||
|
if (!$categories) {
|
||||||
|
echo '<p>' . __('No categories found.', 'cannaiq-menus') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$layout = $settings['layout'];
|
||||||
|
$columns = $settings['columns'];
|
||||||
|
$link_base = $settings['link_to_products']['url'] ?? '';
|
||||||
|
|
||||||
|
$container_class = 'cannaiq-category-' . $layout;
|
||||||
|
if ($layout === 'grid') {
|
||||||
|
$container_class .= ' cannaiq-grid-cols-' . $columns;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="<?php echo esc_attr($container_class); ?>">
|
||||||
|
<?php foreach ($categories as $category):
|
||||||
|
$cat_name = $category['type'] ?? $category['name'] ?? '';
|
||||||
|
$display_name = ucwords(str_replace('_', ' ', $cat_name));
|
||||||
|
$product_count = $category['product_count'] ?? 0;
|
||||||
|
$cat_url = $link_base ? $link_base . '?category=' . urlencode($cat_name) : '#';
|
||||||
|
?>
|
||||||
|
<a href="<?php echo esc_url($cat_url); ?>" class="cannaiq-category-item cannaiq-category-<?php echo esc_attr($layout); ?>-item">
|
||||||
|
<span class="cannaiq-category-name">
|
||||||
|
<?php echo esc_html($display_name); ?>
|
||||||
|
</span>
|
||||||
|
<?php if ($settings['show_product_count'] === 'yes' && $product_count > 0): ?>
|
||||||
|
<span class="cannaiq-category-count">
|
||||||
|
(<?php echo esc_html($product_count); ?>)
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,12 +47,37 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
|
|||||||
);
|
);
|
||||||
|
|
||||||
$this->add_control(
|
$this->add_control(
|
||||||
'category_id',
|
'category',
|
||||||
[
|
[
|
||||||
'label' => __('Category ID', 'cannaiq-menus'),
|
'label' => __('Category', 'cannaiq-menus'),
|
||||||
'type' => \Elementor\Controls_Manager::NUMBER,
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
'default' => '',
|
'default' => '',
|
||||||
'description' => __('Leave empty to show all categories', 'cannaiq-menus'),
|
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
|
||||||
|
'description' => __('Filter by product category', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'brand',
|
||||||
|
[
|
||||||
|
'label' => __('Brand', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '',
|
||||||
|
'options' => CannaIQ_Menus_Plugin::instance()->get_brand_options(),
|
||||||
|
'description' => __('Filter by brand', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'on_special',
|
||||||
|
[
|
||||||
|
'label' => __('On Special Only', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'no',
|
||||||
|
'description' => __('Show only products on sale', 'cannaiq-menus'),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -243,8 +268,16 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
|
|||||||
'in_stock' => $settings['in_stock_only'] === 'yes' ? 'true' : 'false',
|
'in_stock' => $settings['in_stock_only'] === 'yes' ? 'true' : 'false',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!empty($settings['category_id'])) {
|
if (!empty($settings['category'])) {
|
||||||
$args['category_id'] = $settings['category_id'];
|
$args['type'] = $settings['category'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($settings['brand'])) {
|
||||||
|
$args['brandName'] = $settings['brand'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($settings['on_special'] === 'yes') {
|
||||||
|
$args['on_special'] = 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($settings['search'])) {
|
if (!empty($settings['search'])) {
|
||||||
|
|||||||
288
wordpress-plugin/widgets/specials-grid.php
Normal file
288
wordpress-plugin/widgets/specials-grid.php
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Elementor Specials/Deals Grid Widget
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CannaIQ_Menus_Specials_Grid_Widget extends \Elementor\Widget_Base {
|
||||||
|
|
||||||
|
public function get_name() {
|
||||||
|
return 'cannaiq_specials_grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_title() {
|
||||||
|
return __('CannaIQ Specials/Deals', 'cannaiq-menus');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_icon() {
|
||||||
|
return 'eicon-price-table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get_categories() {
|
||||||
|
return ['general'];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function register_controls() {
|
||||||
|
|
||||||
|
// Content Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'content_section',
|
||||||
|
[
|
||||||
|
'label' => __('Content', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'store_id',
|
||||||
|
[
|
||||||
|
'label' => __('Store ID', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||||
|
'default' => get_option('cannaiq_default_store_id', 1),
|
||||||
|
'min' => 1,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'limit',
|
||||||
|
[
|
||||||
|
'label' => __('Number of Products', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::NUMBER,
|
||||||
|
'default' => 8,
|
||||||
|
'min' => 1,
|
||||||
|
'max' => 50,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'columns',
|
||||||
|
[
|
||||||
|
'label' => __('Columns', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '4',
|
||||||
|
'options' => [
|
||||||
|
'2' => __('2 Columns', 'cannaiq-menus'),
|
||||||
|
'3' => __('3 Columns', 'cannaiq-menus'),
|
||||||
|
'4' => __('4 Columns', 'cannaiq-menus'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'category',
|
||||||
|
[
|
||||||
|
'label' => __('Category', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SELECT,
|
||||||
|
'default' => '',
|
||||||
|
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
|
||||||
|
'description' => __('Filter specials by category', 'cannaiq-menus'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Display Options Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'display_section',
|
||||||
|
[
|
||||||
|
'label' => __('Display Options', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_image',
|
||||||
|
[
|
||||||
|
'label' => __('Show Image', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_discount_badge',
|
||||||
|
[
|
||||||
|
'label' => __('Show Discount Badge', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_original_price',
|
||||||
|
[
|
||||||
|
'label' => __('Show Original Price', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'yes',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'show_thc',
|
||||||
|
[
|
||||||
|
'label' => __('Show THC', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SWITCHER,
|
||||||
|
'label_on' => __('Yes', 'cannaiq-menus'),
|
||||||
|
'label_off' => __('No', 'cannaiq-menus'),
|
||||||
|
'return_value' => 'yes',
|
||||||
|
'default' => 'no',
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
|
||||||
|
// Style Section
|
||||||
|
$this->start_controls_section(
|
||||||
|
'style_section',
|
||||||
|
[
|
||||||
|
'label' => __('Style', 'cannaiq-menus'),
|
||||||
|
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_background',
|
||||||
|
[
|
||||||
|
'label' => __('Card Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ffffff',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-special-card' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'badge_background',
|
||||||
|
[
|
||||||
|
'label' => __('Badge Background', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#ef4444',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-discount-badge' => 'background-color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'sale_price_color',
|
||||||
|
[
|
||||||
|
'label' => __('Sale Price Color', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::COLOR,
|
||||||
|
'default' => '#16a34a',
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-price-sale' => 'color: {{VALUE}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->add_control(
|
||||||
|
'card_border_radius',
|
||||||
|
[
|
||||||
|
'label' => __('Border Radius', 'cannaiq-menus'),
|
||||||
|
'type' => \Elementor\Controls_Manager::SLIDER,
|
||||||
|
'size_units' => ['px'],
|
||||||
|
'range' => [
|
||||||
|
'px' => [
|
||||||
|
'min' => 0,
|
||||||
|
'max' => 50,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'default' => [
|
||||||
|
'size' => 8,
|
||||||
|
],
|
||||||
|
'selectors' => [
|
||||||
|
'{{WRAPPER}} .cannaiq-special-card' => 'border-radius: {{SIZE}}{{UNIT}};',
|
||||||
|
],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->end_controls_section();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render() {
|
||||||
|
$settings = $this->get_settings_for_display();
|
||||||
|
|
||||||
|
$args = [
|
||||||
|
'store_id' => $settings['store_id'],
|
||||||
|
'limit' => $settings['limit'],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($settings['category'])) {
|
||||||
|
$args['type'] = $settings['category'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$plugin = CannaIQ_Menus_Plugin::instance();
|
||||||
|
$products = $plugin->fetch_specials($args);
|
||||||
|
|
||||||
|
if (!$products) {
|
||||||
|
echo '<p>' . __('No specials found.', 'cannaiq-menus') . '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$columns = $settings['columns'];
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-specials-grid cannaiq-grid-cols-<?php echo esc_attr($columns); ?>">
|
||||||
|
<?php foreach ($products as $product):
|
||||||
|
$image_url = $product['image_url'] ?? $product['primary_image_url'] ?? '';
|
||||||
|
$product_url = !empty($product['menu_url']) ? $product['menu_url'] : '#';
|
||||||
|
$regular_price = $product['regular_price'] ?? 0;
|
||||||
|
$sale_price = $product['sale_price'] ?? $regular_price;
|
||||||
|
$discount = ($regular_price > 0 && $sale_price < $regular_price)
|
||||||
|
? round((($regular_price - $sale_price) / $regular_price) * 100)
|
||||||
|
: 0;
|
||||||
|
?>
|
||||||
|
<div class="cannaiq-special-card"
|
||||||
|
<?php if ($product_url !== '#'): ?>onclick="window.open('<?php echo esc_url($product_url); ?>', '_blank')"<?php endif; ?>
|
||||||
|
style="cursor: <?php echo ($product_url !== '#') ? 'pointer' : 'default'; ?>;">
|
||||||
|
|
||||||
|
<?php if ($settings['show_discount_badge'] === 'yes' && $discount > 0): ?>
|
||||||
|
<div class="cannaiq-discount-badge">
|
||||||
|
-<?php echo esc_html($discount); ?>%
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($settings['show_image'] === 'yes' && !empty($image_url)): ?>
|
||||||
|
<div class="cannaiq-special-image">
|
||||||
|
<img src="<?php echo esc_url($image_url); ?>"
|
||||||
|
alt="<?php echo esc_attr($product['name']); ?>"
|
||||||
|
loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="cannaiq-special-content">
|
||||||
|
<h3 class="cannaiq-special-title">
|
||||||
|
<?php echo esc_html($product['name']); ?>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<?php if ($settings['show_thc'] === 'yes' && !empty($product['thc_percentage'])): ?>
|
||||||
|
<span class="cannaiq-meta-item cannaiq-thc">
|
||||||
|
THC: <?php echo esc_html($product['thc_percentage']); ?>%
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="cannaiq-special-price">
|
||||||
|
<span class="cannaiq-price-sale">$<?php echo esc_html($sale_price); ?></span>
|
||||||
|
<?php if ($settings['show_original_price'] === 'yes' && $regular_price > $sale_price): ?>
|
||||||
|
<span class="cannaiq-price-regular cannaiq-strikethrough">$<?php echo esc_html($regular_price); ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user