Compare commits

..

1 Commits

Author SHA1 Message Date
Kelly
754a46c56f chore: trigger CI rebuild
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-16 09:19:52 -07:00
98 changed files with 410 additions and 19887 deletions

View File

@@ -76,13 +76,15 @@ steps:
- /kaniko/executor - /kaniko/executor
--context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/backend --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/backend
--dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/backend/Dockerfile --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/backend/Dockerfile
--destination=registry.spdy.io/cannaiq/backend:latest --destination=10.100.9.70:5000/cannaiq/backend:latest
--destination=registry.spdy.io/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8} --destination=10.100.9.70:5000/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8}
--build-arg=APP_BUILD_VERSION=sha-${CI_COMMIT_SHA:0:8} --build-arg=APP_BUILD_VERSION=sha-${CI_COMMIT_SHA:0:8}
--build-arg=APP_GIT_SHA=${CI_COMMIT_SHA} --build-arg=APP_GIT_SHA=${CI_COMMIT_SHA}
--build-arg=APP_BUILD_TIME=${CI_PIPELINE_CREATED} --build-arg=APP_BUILD_TIME=${CI_PIPELINE_CREATED}
--registry-mirror=10.100.9.70:5000
--insecure-registry=10.100.9.70:5000
--cache=true --cache=true
--cache-repo=registry.spdy.io/cannaiq/cache-backend --cache-repo=10.100.9.70:5000/cannaiq/cache-backend
--cache-ttl=168h --cache-ttl=168h
depends_on: [] depends_on: []
when: when:
@@ -95,10 +97,12 @@ steps:
- /kaniko/executor - /kaniko/executor
--context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/cannaiq --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/cannaiq
--dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/cannaiq/Dockerfile --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/cannaiq/Dockerfile
--destination=registry.spdy.io/cannaiq/frontend:latest --destination=10.100.9.70:5000/cannaiq/frontend:latest
--destination=registry.spdy.io/cannaiq/frontend:sha-${CI_COMMIT_SHA:0:8} --destination=10.100.9.70:5000/cannaiq/frontend:sha-${CI_COMMIT_SHA:0:8}
--registry-mirror=10.100.9.70:5000
--insecure-registry=10.100.9.70:5000
--cache=true --cache=true
--cache-repo=registry.spdy.io/cannaiq/cache-cannaiq --cache-repo=10.100.9.70:5000/cannaiq/cache-cannaiq
--cache-ttl=168h --cache-ttl=168h
depends_on: [] depends_on: []
when: when:
@@ -111,10 +115,12 @@ steps:
- /kaniko/executor - /kaniko/executor
--context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findadispo/frontend --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findadispo/frontend
--dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findadispo/frontend/Dockerfile --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findadispo/frontend/Dockerfile
--destination=registry.spdy.io/cannaiq/findadispo:latest --destination=10.100.9.70:5000/cannaiq/findadispo:latest
--destination=registry.spdy.io/cannaiq/findadispo:sha-${CI_COMMIT_SHA:0:8} --destination=10.100.9.70:5000/cannaiq/findadispo:sha-${CI_COMMIT_SHA:0:8}
--registry-mirror=10.100.9.70:5000
--insecure-registry=10.100.9.70:5000
--cache=true --cache=true
--cache-repo=registry.spdy.io/cannaiq/cache-findadispo --cache-repo=10.100.9.70:5000/cannaiq/cache-findadispo
--cache-ttl=168h --cache-ttl=168h
depends_on: [] depends_on: []
when: when:
@@ -127,10 +133,12 @@ steps:
- /kaniko/executor - /kaniko/executor
--context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findagram/frontend --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findagram/frontend
--dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findagram/frontend/Dockerfile --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findagram/frontend/Dockerfile
--destination=registry.spdy.io/cannaiq/findagram:latest --destination=10.100.9.70:5000/cannaiq/findagram:latest
--destination=registry.spdy.io/cannaiq/findagram:sha-${CI_COMMIT_SHA:0:8} --destination=10.100.9.70:5000/cannaiq/findagram:sha-${CI_COMMIT_SHA:0:8}
--registry-mirror=10.100.9.70:5000
--insecure-registry=10.100.9.70:5000
--cache=true --cache=true
--cache-repo=registry.spdy.io/cannaiq/cache-findagram --cache-repo=10.100.9.70:5000/cannaiq/cache-findagram
--cache-ttl=168h --cache-ttl=168h
depends_on: [] depends_on: []
when: when:
@@ -169,16 +177,14 @@ steps:
token: $K8S_TOKEN token: $K8S_TOKEN
KUBEEOF KUBEEOF
- chmod 600 ~/.kube/config - chmod 600 ~/.kube/config
# Apply manifests to ensure probes and resource limits are set - kubectl set image deployment/scraper scraper=10.100.9.70:5000/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- kubectl apply -f /woodpecker/src/git.spdy.io/Creationshop/cannaiq/k8s/scraper.yaml
- kubectl apply -f /woodpecker/src/git.spdy.io/Creationshop/cannaiq/k8s/scraper-worker.yaml
- kubectl set image deployment/scraper scraper=registry.spdy.io/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- kubectl rollout status deployment/scraper -n cannaiq --timeout=300s - kubectl rollout status deployment/scraper -n cannaiq --timeout=300s
- kubectl set image deployment/scraper-worker worker=registry.spdy.io/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq - REPLICAS=$(kubectl get deployment scraper-worker -n cannaiq -o jsonpath='{.spec.replicas}'); if [ "$REPLICAS" = "0" ]; then kubectl scale deployment/scraper-worker --replicas=5 -n cannaiq; fi
- kubectl set image deployment/cannaiq-frontend cannaiq-frontend=registry.spdy.io/cannaiq/frontend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq - kubectl set image deployment/scraper-worker worker=10.100.9.70:5000/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- kubectl set image deployment/findadispo-frontend findadispo-frontend=registry.spdy.io/cannaiq/findadispo:sha-${CI_COMMIT_SHA:0:8} -n cannaiq - kubectl set image deployment/cannaiq-frontend cannaiq-frontend=10.100.9.70:5000/cannaiq/frontend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- kubectl set image deployment/findagram-frontend findagram-frontend=registry.spdy.io/cannaiq/findagram:sha-${CI_COMMIT_SHA:0:8} -n cannaiq - kubectl set image deployment/findadispo-frontend findadispo-frontend=10.100.9.70:5000/cannaiq/findadispo:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- kubectl rollout status deployment/cannaiq-frontend -n cannaiq --timeout=300s - kubectl set image deployment/findagram-frontend findagram-frontend=10.100.9.70:5000/cannaiq/findagram:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- kubectl rollout status deployment/cannaiq-frontend -n cannaiq --timeout=120s
depends_on: depends_on:
- docker-backend - docker-backend
- docker-cannaiq - docker-cannaiq

194
CLAUDE.md
View File

@@ -42,49 +42,53 @@ Never import `src/db/migrate.ts` at runtime. Use `src/db/pool.ts` for DB access.
Batch everything, push once, wait for user feedback. Batch everything, push once, wait for user feedback.
### 7. K8S — DEPLOY AND FORGET ### 7. K8S POD LIMITS — CRITICAL
**DO NOT run kubectl commands.** The system is self-managing. **EXACTLY 8 PODS** for `scraper-worker` deployment. NEVER CHANGE THIS.
**Operational Model:** **Replica Count is LOCKED:**
``` - Always 8 replicas — no more, no less
┌─────────────────────────────────────────────────────────┐ - NEVER scale down (even temporarily)
│ DEPLOY ONCE → WORKERS RUN FOREVER → CREATE TASKS ONLY │ - NEVER scale up beyond 8
└─────────────────────────────────────────────────────────┘ - If pods are not 8, restore to 8 immediately
1. CI deploys code changes (automatic on push) **Pods vs Workers:**
2. K8s maintains 8 pods (self-healing) - **Pod** = Kubernetes container instance (ALWAYS 8)
3. Workers poll DB for tasks (autonomous) - **Worker** = Concurrent task runner INSIDE a pod (controlled by `MAX_CONCURRENT_TASKS` env var)
4. Create tasks via API or DB → workers pick them up - Formula: `8 pods × MAX_CONCURRENT_TASKS = 24 total concurrent workers`
5. Never touch K8s directly
**Browser Task Memory Limits:**
- Each Puppeteer/Chrome browser uses ~400 MB RAM
- Pod memory limit is 2 GB
- **MAX_CONCURRENT_TASKS=3** is the safe maximum for browser tasks
- More than 3 concurrent browsers per pod = OOM crash
| Browsers | RAM Used | Status |
|----------|----------|--------|
| 3 | ~1.3 GB | Safe (recommended) |
| 4 | ~1.7 GB | Risky |
| 5+ | >2 GB | OOM crash |
**To increase throughput:** Add more pods (up to 8), NOT more concurrent tasks per pod.
```bash
# CORRECT - scale pods (up to 8)
kubectl scale deployment/scraper-worker -n dispensary-scraper --replicas=8
# WRONG - will cause OOM crashes
kubectl set env deployment/scraper-worker -n dispensary-scraper MAX_CONCURRENT_TASKS=10
``` ```
**Fixed Configuration (NEVER CHANGE):** **If K8s API returns ServiceUnavailable:** STOP IMMEDIATELY. Do not retry. The cluster is overloaded.
- **8 replicas** — locked in `k8s/scraper-worker.yaml`
- **MAX_CONCURRENT_TASKS=3** — 3 browsers per pod (memory safe)
- **Total capacity:** 8 pods × 3 = 24 concurrent tasks
**DO NOT:** ### 7. K8S REQUIRES EXPLICIT PERMISSION
- Run `kubectl` commands (scale, rollout, logs, get pods, etc.) **NEVER run kubectl commands without explicit user permission.**
- Manually restart pods
- Change replica count
- Check deployment status
**To interact with the system:** Before running ANY `kubectl` command (scale, rollout, set env, delete, apply, etc.):
- Create tasks in DB → workers pick them up automatically 1. Tell the user what you want to do
- Check task status via DB queries or API 2. Wait for explicit approval
- View worker status via dashboard (cannaiq.co) 3. Only then execute the command
**Why no kubectl?** This applies to ALL kubectl operations - even read-only ones like `kubectl get pods`.
- K8s auto-restarts crashed pods
- Workers self-heal (reconnect to DB, retry failed tasks)
- No manual intervention needed in steady state
- Only CI touches K8s (on code deployments)
**Scaling Decision:**
- Monitor pool drain rate via dashboard/DB queries
- If pool drains too slowly, manually increase replicas in `k8s/scraper-worker.yaml`
- Commit + push → CI deploys new replica count
- No runtime kubectl scaling — all changes via code
--- ---
@@ -290,7 +294,7 @@ Workers use Evomi's residential proxy API for geo-targeted proxies on-demand.
**K8s Secret**: Credentials stored in `scraper-secrets`: **K8s Secret**: Credentials stored in `scraper-secrets`:
```bash ```bash
kubectl get secret scraper-secrets -n cannaiq -o jsonpath='{.data.EVOMI_PASS}' | base64 -d kubectl get secret scraper-secrets -n dispensary-scraper -o jsonpath='{.data.EVOMI_PASS}' | base64 -d
``` ```
**Proxy URL Format**: `http://{user}_{session}_{geo}:{pass}@{host}:{port}` **Proxy URL Format**: `http://{user}_{session}_{geo}:{pass}@{host}:{port}`
@@ -369,122 +373,6 @@ curl -X POST http://localhost:3010/api/tasks/crawl-state/AZ \
--- ---
## Wasabi S3 Storage (Payload Archive)
Raw crawl payloads are archived to Wasabi S3 for long-term storage and potential reprocessing.
### Configuration
| Variable | Description | Default |
|----------|-------------|---------|
| `WASABI_ACCESS_KEY` | Wasabi access key ID | - |
| `WASABI_SECRET_KEY` | Wasabi secret access key | - |
| `WASABI_BUCKET` | Bucket name | `cannaiq` |
| `WASABI_REGION` | Wasabi region | `us-west-2` |
| `WASABI_ENDPOINT` | S3 endpoint URL | `https://s3.us-west-2.wasabisys.com` |
### Storage Path Format
```
payloads/{state}/{YYYY-MM-DD}/{dispensary_id}/{platform}_{timestamp}.json.gz
```
Example: `payloads/AZ/2025-12-16/123/dutchie_2025-12-16T10-30-00-000Z.json.gz`
### Features
- **Gzip compression**: ~70% size reduction on JSON payloads
- **Automatic archival**: Every crawl is archived (not just daily baselines)
- **Metadata**: taskId, productCount, platform stored with each object
- **Graceful fallback**: If Wasabi not configured, archival is skipped (no task failure)
### Files
| File | Purpose |
|------|---------|
| `src/services/wasabi-storage.ts` | S3 client and storage functions |
| `src/tasks/handlers/product-discovery-dutchie.ts` | Archives Dutchie payloads |
| `src/tasks/handlers/product-discovery-jane.ts` | Archives Jane payloads |
| `src/tasks/handlers/product-discovery-treez.ts` | Archives Treez payloads |
### K8s Secret Setup
```bash
kubectl patch secret scraper-secrets -n cannaiq -p '{"stringData":{
"WASABI_ACCESS_KEY": "<access-key>",
"WASABI_SECRET_KEY": "<secret-key>"
}}'
```
### Usage in Code
```typescript
import { storePayload, getPayload, listPayloads } from '../services/wasabi-storage';
// Store a payload
const result = await storePayload(dispensaryId, 'AZ', 'dutchie', rawPayload);
console.log(result.path); // payloads/AZ/2025-12-16/123/dutchie_...
console.log(result.compressedBytes); // Size after gzip
// Retrieve a payload
const payload = await getPayload(result.path);
// List payloads for a store on a date
const paths = await listPayloads(123, 'AZ', '2025-12-16');
```
### Estimated Storage
- ~100KB per crawl (compressed)
- ~200 stores × 12 crawls/day = 240MB/day
- ~7.2GB/month
- 5TB capacity = ~5+ years of storage
---
## Real-Time Inventory Tracking
High-frequency crawling for sales velocity and inventory analytics.
### Crawl Intervals
| State | Interval | Jitter | Effective Range |
|-------|----------|--------|-----------------|
| AZ | 5 min | ±3 min | 2-8 min |
| Others | 60 min | ±3 min | 57-63 min |
### Delta-Only Snapshots
Only store inventory changes, not full state. Reduces storage by ~95%.
**Change Types**:
- `sale`: quantity decreased (qty_delta < 0)
- `restock`: quantity increased (qty_delta > 0)
- `price_change`: price changed, quantity same
- `oos`: went out of stock (qty → 0)
- `back_in_stock`: returned to stock (0 → qty)
- `new_product`: first time seeing product
### Revenue Calculation
```
revenue = ABS(qty_delta) × effective_price
effective_price = sale_price if on_special else regular_price
```
### Key Views
| View | Purpose |
|------|---------|
| `v_hourly_sales` | Sales aggregated by hour |
| `v_daily_store_sales` | Daily revenue by store |
| `v_daily_brand_sales` | Daily brand performance |
| `v_product_velocity` | Hot/steady/slow/stale rankings |
| `v_stock_out_prediction` | Days until OOS based on velocity |
| `v_brand_variants` | SKU counts per brand |
### Files
| File | Purpose |
|------|---------|
| `src/services/inventory-snapshots.ts` | Delta calculation and storage |
| `src/services/task-scheduler.ts` | High-frequency scheduling with jitter |
| `migrations/125_delta_only_snapshots.sql` | Delta columns and views |
| `migrations/126_az_high_frequency.sql` | AZ 5-min intervals |
---
## Documentation ## Documentation
| Doc | Purpose | | Doc | Purpose |

View File

@@ -1,383 +0,0 @@
-- Migration 121: Sales Analytics Materialized Views
-- Pre-computed views for sales velocity, brand market share, and store performance
-- ============================================================
-- VIEW 1: Daily Sales Estimates (per product/store)
-- Calculates delta between consecutive snapshots
-- ============================================================
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_daily_sales_estimates AS
WITH qty_deltas AS (
SELECT
dispensary_id,
product_id,
brand_name,
category,
DATE(captured_at) AS sale_date,
price_rec,
quantity_available,
LAG(quantity_available) OVER (
PARTITION BY dispensary_id, product_id
ORDER BY captured_at
) AS prev_quantity
FROM inventory_snapshots
WHERE quantity_available IS NOT NULL
AND captured_at >= NOW() - INTERVAL '30 days'
)
SELECT
dispensary_id,
product_id,
brand_name,
category,
sale_date,
AVG(price_rec) AS avg_price,
SUM(GREATEST(0, COALESCE(prev_quantity, 0) - quantity_available)) AS units_sold,
SUM(GREATEST(0, quantity_available - COALESCE(prev_quantity, 0))) AS units_restocked,
SUM(GREATEST(0, COALESCE(prev_quantity, 0) - quantity_available) * COALESCE(price_rec, 0)) AS revenue_estimate,
COUNT(*) AS snapshot_count
FROM qty_deltas
WHERE prev_quantity IS NOT NULL
GROUP BY dispensary_id, product_id, brand_name, category, sale_date;
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_daily_sales_pk
ON mv_daily_sales_estimates(dispensary_id, product_id, sale_date);
CREATE INDEX IF NOT EXISTS idx_mv_daily_sales_brand
ON mv_daily_sales_estimates(brand_name, sale_date);
CREATE INDEX IF NOT EXISTS idx_mv_daily_sales_category
ON mv_daily_sales_estimates(category, sale_date);
CREATE INDEX IF NOT EXISTS idx_mv_daily_sales_date
ON mv_daily_sales_estimates(sale_date DESC);
-- ============================================================
-- VIEW 2: Brand Market Share by State
-- Weighted distribution across stores
-- ============================================================
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_brand_market_share AS
WITH brand_presence AS (
SELECT
sp.brand AS brand_name,
d.state AS state_code,
COUNT(DISTINCT sp.dispensary_id) AS stores_carrying,
COUNT(*) AS sku_count,
SUM(CASE WHEN sp.is_in_stock THEN 1 ELSE 0 END) AS in_stock_skus,
AVG(sp.price_rec) AS avg_price
FROM store_products sp
JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE sp.brand IS NOT NULL
AND d.state IS NOT NULL
GROUP BY sp.brand, d.state
),
state_totals AS (
SELECT
d.state AS state_code,
COUNT(DISTINCT d.id) FILTER (WHERE d.crawl_enabled) AS total_stores
FROM dispensaries d
WHERE d.state IS NOT NULL
GROUP BY d.state
)
SELECT
bp.brand_name,
bp.state_code,
bp.stores_carrying,
st.total_stores,
ROUND(bp.stores_carrying::NUMERIC * 100 / NULLIF(st.total_stores, 0), 2) AS penetration_pct,
bp.sku_count,
bp.in_stock_skus,
bp.avg_price,
NOW() AS calculated_at
FROM brand_presence bp
JOIN state_totals st ON st.state_code = bp.state_code;
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_brand_market_pk
ON mv_brand_market_share(brand_name, state_code);
CREATE INDEX IF NOT EXISTS idx_mv_brand_market_state
ON mv_brand_market_share(state_code);
CREATE INDEX IF NOT EXISTS idx_mv_brand_market_penetration
ON mv_brand_market_share(penetration_pct DESC);
-- ============================================================
-- VIEW 3: SKU Velocity (30-day rolling)
-- Average daily units sold per SKU
-- ============================================================
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_sku_velocity AS
SELECT
dse.product_id,
dse.brand_name,
dse.category,
dse.dispensary_id,
d.name AS dispensary_name,
d.state AS state_code,
SUM(dse.units_sold) AS total_units_30d,
SUM(dse.revenue_estimate) AS total_revenue_30d,
COUNT(DISTINCT dse.sale_date) AS days_with_sales,
ROUND(SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0), 2) AS avg_daily_units,
AVG(dse.avg_price) AS avg_price,
CASE
WHEN SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0) >= 5 THEN 'hot'
WHEN SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0) >= 1 THEN 'steady'
WHEN SUM(dse.units_sold)::NUMERIC / NULLIF(COUNT(DISTINCT dse.sale_date), 0) >= 0.1 THEN 'slow'
ELSE 'stale'
END AS velocity_tier,
NOW() AS calculated_at
FROM mv_daily_sales_estimates dse
JOIN dispensaries d ON d.id = dse.dispensary_id
WHERE dse.sale_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY dse.product_id, dse.brand_name, dse.category, dse.dispensary_id, d.name, d.state;
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_sku_velocity_pk
ON mv_sku_velocity(dispensary_id, product_id);
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_brand
ON mv_sku_velocity(brand_name);
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_tier
ON mv_sku_velocity(velocity_tier);
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_state
ON mv_sku_velocity(state_code);
CREATE INDEX IF NOT EXISTS idx_mv_sku_velocity_units
ON mv_sku_velocity(total_units_30d DESC);
-- ============================================================
-- VIEW 4: Store Performance Rankings
-- Revenue estimates and brand diversity per store
-- ============================================================
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_store_performance AS
SELECT
d.id AS dispensary_id,
d.name AS dispensary_name,
d.city,
d.state AS state_code,
-- Revenue metrics from sales estimates
COALESCE(sales.total_revenue_30d, 0) AS total_revenue_30d,
COALESCE(sales.total_units_30d, 0) AS total_units_30d,
-- Inventory metrics
COUNT(DISTINCT sp.id) AS total_skus,
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_in_stock) AS in_stock_skus,
-- Brand diversity
COUNT(DISTINCT sp.brand) AS unique_brands,
COUNT(DISTINCT sp.category) AS unique_categories,
-- Pricing
AVG(sp.price_rec) AS avg_price,
-- Activity
MAX(sp.updated_at) AS last_updated,
NOW() AS calculated_at
FROM dispensaries d
LEFT JOIN store_products sp ON sp.dispensary_id = d.id
LEFT JOIN (
SELECT
dispensary_id,
SUM(revenue_estimate) AS total_revenue_30d,
SUM(units_sold) AS total_units_30d
FROM mv_daily_sales_estimates
WHERE sale_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY dispensary_id
) sales ON sales.dispensary_id = d.id
WHERE d.crawl_enabled = TRUE
GROUP BY d.id, d.name, d.city, d.state, sales.total_revenue_30d, sales.total_units_30d;
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_store_perf_pk
ON mv_store_performance(dispensary_id);
CREATE INDEX IF NOT EXISTS idx_mv_store_perf_state
ON mv_store_performance(state_code);
CREATE INDEX IF NOT EXISTS idx_mv_store_perf_revenue
ON mv_store_performance(total_revenue_30d DESC);
-- ============================================================
-- VIEW 5: Weekly Category Trends
-- Category performance over time
-- ============================================================
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_category_weekly_trends AS
SELECT
dse.category,
d.state AS state_code,
DATE_TRUNC('week', dse.sale_date)::DATE AS week_start,
COUNT(DISTINCT dse.product_id) AS sku_count,
COUNT(DISTINCT dse.dispensary_id) AS store_count,
SUM(dse.units_sold) AS total_units,
SUM(dse.revenue_estimate) AS total_revenue,
AVG(dse.avg_price) AS avg_price,
NOW() AS calculated_at
FROM mv_daily_sales_estimates dse
JOIN dispensaries d ON d.id = dse.dispensary_id
WHERE dse.category IS NOT NULL
AND dse.sale_date >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY dse.category, d.state, DATE_TRUNC('week', dse.sale_date);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_cat_weekly_pk
ON mv_category_weekly_trends(category, state_code, week_start);
CREATE INDEX IF NOT EXISTS idx_mv_cat_weekly_state
ON mv_category_weekly_trends(state_code, week_start);
CREATE INDEX IF NOT EXISTS idx_mv_cat_weekly_date
ON mv_category_weekly_trends(week_start DESC);
-- ============================================================
-- VIEW 6: Product Intelligence (Hoodie-style per-product metrics)
-- Includes stock diff, days since OOS, days until stockout
-- ============================================================
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_product_intelligence AS
WITH
-- Calculate stock diff over 120 days
stock_diff AS (
SELECT
dispensary_id,
product_id,
-- Get oldest and newest quantity in last 120 days
FIRST_VALUE(quantity_available) OVER (
PARTITION BY dispensary_id, product_id
ORDER BY captured_at ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS qty_120d_ago,
LAST_VALUE(quantity_available) OVER (
PARTITION BY dispensary_id, product_id
ORDER BY captured_at ASC
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS qty_current
FROM inventory_snapshots
WHERE captured_at >= NOW() - INTERVAL '120 days'
),
stock_diff_calc AS (
SELECT DISTINCT
dispensary_id,
product_id,
qty_current - COALESCE(qty_120d_ago, qty_current) AS stock_diff_120
FROM stock_diff
),
-- Get days since last OOS event
last_oos AS (
SELECT
dispensary_id,
product_id,
MAX(detected_at) AS last_oos_date
FROM product_visibility_events
WHERE event_type = 'oos'
GROUP BY dispensary_id, product_id
),
-- Calculate avg daily units sold (from velocity view)
velocity AS (
SELECT
dispensary_id,
product_id,
avg_daily_units
FROM mv_sku_velocity
)
SELECT
sp.dispensary_id,
d.name AS dispensary_name,
d.state AS state_code,
d.city,
sp.provider_product_id AS sku,
sp.name_raw AS product_name,
sp.brand_name_raw AS brand,
sp.category_raw AS category,
sp.is_in_stock,
sp.stock_status,
sp.stock_quantity,
sp.price_rec AS price,
sp.first_seen_at AS first_seen,
sp.last_seen_at AS last_seen,
-- Calculated fields
COALESCE(sd.stock_diff_120, 0) AS stock_diff_120,
CASE
WHEN lo.last_oos_date IS NOT NULL
THEN EXTRACT(DAY FROM NOW() - lo.last_oos_date)::INT
ELSE NULL
END AS days_since_oos,
-- Days until stockout = current stock / daily burn rate
CASE
WHEN v.avg_daily_units > 0 AND sp.stock_quantity > 0
THEN ROUND(sp.stock_quantity::NUMERIC / v.avg_daily_units)::INT
ELSE NULL
END AS days_until_stock_out,
v.avg_daily_units,
NOW() AS calculated_at
FROM store_products sp
JOIN dispensaries d ON d.id = sp.dispensary_id
LEFT JOIN stock_diff_calc sd ON sd.dispensary_id = sp.dispensary_id
AND sd.product_id = sp.provider_product_id
LEFT JOIN last_oos lo ON lo.dispensary_id = sp.dispensary_id
AND lo.product_id = sp.provider_product_id
LEFT JOIN velocity v ON v.dispensary_id = sp.dispensary_id
AND v.product_id = sp.provider_product_id
WHERE d.crawl_enabled = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_mv_prod_intel_pk
ON mv_product_intelligence(dispensary_id, sku);
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_brand
ON mv_product_intelligence(brand);
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_state
ON mv_product_intelligence(state_code);
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_stock_out
ON mv_product_intelligence(days_until_stock_out ASC NULLS LAST);
CREATE INDEX IF NOT EXISTS idx_mv_prod_intel_oos
ON mv_product_intelligence(days_since_oos DESC NULLS LAST);
-- ============================================================
-- REFRESH FUNCTION
-- ============================================================
CREATE OR REPLACE FUNCTION refresh_sales_analytics_views()
RETURNS TABLE(view_name TEXT, rows_affected BIGINT) AS $$
DECLARE
row_count BIGINT;
BEGIN
-- Must refresh in dependency order:
-- 1. daily_sales (base view)
-- 2. sku_velocity (depends on daily_sales)
-- 3. product_intelligence (depends on sku_velocity)
-- 4. others (independent)
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_daily_sales_estimates;
SELECT COUNT(*) INTO row_count FROM mv_daily_sales_estimates;
view_name := 'mv_daily_sales_estimates';
rows_affected := row_count;
RETURN NEXT;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_brand_market_share;
SELECT COUNT(*) INTO row_count FROM mv_brand_market_share;
view_name := 'mv_brand_market_share';
rows_affected := row_count;
RETURN NEXT;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_sku_velocity;
SELECT COUNT(*) INTO row_count FROM mv_sku_velocity;
view_name := 'mv_sku_velocity';
rows_affected := row_count;
RETURN NEXT;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_store_performance;
SELECT COUNT(*) INTO row_count FROM mv_store_performance;
view_name := 'mv_store_performance';
rows_affected := row_count;
RETURN NEXT;
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_category_weekly_trends;
SELECT COUNT(*) INTO row_count FROM mv_category_weekly_trends;
view_name := 'mv_category_weekly_trends';
rows_affected := row_count;
RETURN NEXT;
-- Product intelligence depends on sku_velocity, so refresh last
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_product_intelligence;
SELECT COUNT(*) INTO row_count FROM mv_product_intelligence;
view_name := 'mv_product_intelligence';
rows_affected := row_count;
RETURN NEXT;
END;
$$ LANGUAGE plpgsql;
COMMENT ON FUNCTION refresh_sales_analytics_views IS
'Refresh all sales analytics materialized views. Call hourly via scheduler.';
-- ============================================================
-- INITIAL REFRESH (populate views)
-- ============================================================
-- Note: Initial refresh must be non-concurrent (no unique index yet populated)
-- Run these manually after migration:
-- REFRESH MATERIALIZED VIEW mv_daily_sales_estimates;
-- REFRESH MATERIALIZED VIEW mv_brand_market_share;
-- REFRESH MATERIALIZED VIEW mv_sku_velocity;
-- REFRESH MATERIALIZED VIEW mv_store_performance;
-- REFRESH MATERIALIZED VIEW mv_category_weekly_trends;

View File

@@ -1,359 +0,0 @@
-- Migration 122: Market Intelligence Schema
-- Separate schema for external market data ingestion
-- Supports product, brand, and dispensary data from third-party sources
-- Create dedicated schema
CREATE SCHEMA IF NOT EXISTS market_intel;
-- ============================================================
-- BRANDS: Brand/Company Intelligence
-- ============================================================
CREATE TABLE IF NOT EXISTS market_intel.brands (
id SERIAL PRIMARY KEY,
-- Identity
brand_name VARCHAR(255) NOT NULL,
parent_brand VARCHAR(255),
parent_company VARCHAR(255),
slug VARCHAR(255),
external_id VARCHAR(255) UNIQUE, -- objectID from source
-- Details
brand_description TEXT,
brand_logo_url TEXT,
brand_url TEXT,
linkedin_url TEXT,
-- Presence
states JSONB DEFAULT '[]', -- Array of state names
active_variants INTEGER DEFAULT 0,
all_variants INTEGER DEFAULT 0,
-- Metadata
source VARCHAR(50) DEFAULT 'external',
fetched_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_brands_name ON market_intel.brands(brand_name);
CREATE INDEX IF NOT EXISTS idx_brands_parent ON market_intel.brands(parent_brand);
CREATE INDEX IF NOT EXISTS idx_brands_external ON market_intel.brands(external_id);
CREATE INDEX IF NOT EXISTS idx_brands_states ON market_intel.brands USING GIN(states);
-- ============================================================
-- DISPENSARIES: Dispensary/Store Intelligence
-- ============================================================
CREATE TABLE IF NOT EXISTS market_intel.dispensaries (
id SERIAL PRIMARY KEY,
-- Identity
dispensary_name VARCHAR(255) NOT NULL,
dispensary_company_name VARCHAR(255),
dispensary_company_id VARCHAR(255),
slug VARCHAR(255),
external_id VARCHAR(255) UNIQUE, -- objectID from source
-- Location
street_address VARCHAR(255),
city VARCHAR(100),
state VARCHAR(100),
postal_code VARCHAR(20),
county_name VARCHAR(100),
country_code VARCHAR(10) DEFAULT 'USA',
full_address TEXT,
latitude DECIMAL(10, 7),
longitude DECIMAL(10, 7),
timezone VARCHAR(50),
urbanicity VARCHAR(50), -- Urban, Suburban, Rural
-- Contact
phone VARCHAR(50),
email VARCHAR(255),
website TEXT,
linkedin_url TEXT,
-- License
license_number VARCHAR(100),
license_type VARCHAR(100),
-- Store Type
is_medical BOOLEAN DEFAULT FALSE,
is_recreational BOOLEAN DEFAULT FALSE,
delivery_enabled BOOLEAN DEFAULT FALSE,
curbside_pickup BOOLEAN DEFAULT FALSE,
instore_pickup BOOLEAN DEFAULT FALSE,
location_type VARCHAR(50), -- RETAIL, DELIVERY, etc.
-- Sales Estimates
estimated_daily_sales DECIMAL(12, 2),
estimated_sales DECIMAL(12, 2),
avg_daily_sales DECIMAL(12, 2),
state_sales_bucket INTEGER,
-- Customer Demographics
affluency JSONB DEFAULT '[]', -- Array of affluency segments
age_skew JSONB DEFAULT '[]', -- Array of age brackets
customer_segments JSONB DEFAULT '[]', -- Array of segment names
-- Inventory Stats
menus_count INTEGER DEFAULT 0,
menus_count_med INTEGER DEFAULT 0,
menus_count_rec INTEGER DEFAULT 0,
parent_brands JSONB DEFAULT '[]',
brand_company_names JSONB DEFAULT '[]',
-- Business Info
banner VARCHAR(255), -- Chain/banner name
business_type VARCHAR(50), -- MSO, Independent, etc.
pos_system VARCHAR(100),
atm_presence BOOLEAN DEFAULT FALSE,
tax_included BOOLEAN DEFAULT FALSE,
-- Ratings
rating DECIMAL(3, 2),
reviews_count INTEGER DEFAULT 0,
-- Status
is_closed BOOLEAN DEFAULT FALSE,
open_date TIMESTAMPTZ,
last_updated_at TIMESTAMPTZ,
-- Media
logo_url TEXT,
cover_url TEXT,
-- Metadata
source VARCHAR(50) DEFAULT 'external',
fetched_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dispensaries_name ON market_intel.dispensaries(dispensary_name);
CREATE INDEX IF NOT EXISTS idx_dispensaries_state ON market_intel.dispensaries(state);
CREATE INDEX IF NOT EXISTS idx_dispensaries_city ON market_intel.dispensaries(city);
CREATE INDEX IF NOT EXISTS idx_dispensaries_external ON market_intel.dispensaries(external_id);
CREATE INDEX IF NOT EXISTS idx_dispensaries_banner ON market_intel.dispensaries(banner);
CREATE INDEX IF NOT EXISTS idx_dispensaries_business_type ON market_intel.dispensaries(business_type);
CREATE INDEX IF NOT EXISTS idx_dispensaries_geo ON market_intel.dispensaries(latitude, longitude);
CREATE INDEX IF NOT EXISTS idx_dispensaries_segments ON market_intel.dispensaries USING GIN(customer_segments);
-- ============================================================
-- PRODUCTS: Product/SKU Intelligence
-- ============================================================
CREATE TABLE IF NOT EXISTS market_intel.products (
id SERIAL PRIMARY KEY,
-- Identity
name VARCHAR(500) NOT NULL,
brand VARCHAR(255),
brand_id VARCHAR(255),
brand_company_name VARCHAR(255),
parent_brand VARCHAR(255),
external_id VARCHAR(255) UNIQUE, -- objectID from source
cm_id VARCHAR(100), -- Canonical menu ID
-- Category Hierarchy
category_0 VARCHAR(100), -- Top level: Flower, Edibles, Vapes
category_1 VARCHAR(255), -- Mid level: Flower > Pre-Rolls
category_2 VARCHAR(500), -- Detailed: Flower > Pre-Rolls > Singles
-- Cannabis Classification
cannabis_type VARCHAR(50), -- SATIVA, INDICA, HYBRID
strain VARCHAR(255),
flavor VARCHAR(255),
pack_size VARCHAR(100),
description TEXT,
-- Cannabinoids
thc_mg DECIMAL(10, 2),
cbd_mg DECIMAL(10, 2),
percent_thc DECIMAL(5, 2),
percent_cbd DECIMAL(5, 2),
-- Dispensary Context (denormalized for query performance)
master_dispensary_name VARCHAR(255),
master_dispensary_id VARCHAR(255),
dispensary_count INTEGER DEFAULT 0, -- How many stores carry this
d_state VARCHAR(100),
d_city VARCHAR(100),
d_banner VARCHAR(255),
d_business_type VARCHAR(50),
d_medical BOOLEAN,
d_recreational BOOLEAN,
-- Customer Demographics (from dispensary)
d_customer_segments JSONB DEFAULT '[]',
d_age_skew JSONB DEFAULT '[]',
d_affluency JSONB DEFAULT '[]',
d_urbanicity VARCHAR(50),
-- Stock Status
in_stock BOOLEAN DEFAULT TRUE,
last_seen_at DATE,
last_seen_at_ts BIGINT,
-- Media
img_url TEXT,
product_url TEXT,
menu_slug VARCHAR(500),
-- Geo
latitude DECIMAL(10, 7),
longitude DECIMAL(10, 7),
-- Metadata
source VARCHAR(50) DEFAULT 'external',
fetched_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_products_name ON market_intel.products(name);
CREATE INDEX IF NOT EXISTS idx_products_brand ON market_intel.products(brand);
CREATE INDEX IF NOT EXISTS idx_products_external ON market_intel.products(external_id);
CREATE INDEX IF NOT EXISTS idx_products_category ON market_intel.products(category_0, category_1);
CREATE INDEX IF NOT EXISTS idx_products_cannabis_type ON market_intel.products(cannabis_type);
CREATE INDEX IF NOT EXISTS idx_products_strain ON market_intel.products(strain);
CREATE INDEX IF NOT EXISTS idx_products_state ON market_intel.products(d_state);
CREATE INDEX IF NOT EXISTS idx_products_in_stock ON market_intel.products(in_stock);
CREATE INDEX IF NOT EXISTS idx_products_dispensary_count ON market_intel.products(dispensary_count DESC);
CREATE INDEX IF NOT EXISTS idx_products_segments ON market_intel.products USING GIN(d_customer_segments);
-- ============================================================
-- PRODUCT_VARIANTS: Variant-Level Data (Pricing, Stock)
-- ============================================================
CREATE TABLE IF NOT EXISTS market_intel.product_variants (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES market_intel.products(id) ON DELETE CASCADE,
-- Identity
variant_id VARCHAR(255) NOT NULL,
pos_sku VARCHAR(255),
pos_product_id VARCHAR(255),
pos_system VARCHAR(100),
-- Pricing
actual_price DECIMAL(10, 2),
original_price DECIMAL(10, 2),
discounted_price DECIMAL(10, 2),
-- Presentation
product_presentation VARCHAR(255), -- "100.00 mg", "3.5g", etc.
quantity DECIMAL(10, 2),
unit VARCHAR(50), -- mg, g, oz, each
-- Availability
is_medical BOOLEAN DEFAULT FALSE,
is_recreational BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
-- Stock Intelligence
stock_status VARCHAR(50), -- In Stock, Low Stock, Out of Stock
stock_diff_120 DECIMAL(10, 2), -- 120-day stock change
days_since_oos INTEGER,
days_until_stock_out INTEGER,
-- Timestamps
first_seen_at_ts BIGINT,
first_seen_at TIMESTAMPTZ,
last_seen_at DATE,
-- Metadata
fetched_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(product_id, variant_id)
);
CREATE INDEX IF NOT EXISTS idx_variants_product ON market_intel.product_variants(product_id);
CREATE INDEX IF NOT EXISTS idx_variants_sku ON market_intel.product_variants(pos_sku);
CREATE INDEX IF NOT EXISTS idx_variants_stock_status ON market_intel.product_variants(stock_status);
CREATE INDEX IF NOT EXISTS idx_variants_price ON market_intel.product_variants(actual_price);
CREATE INDEX IF NOT EXISTS idx_variants_days_out ON market_intel.product_variants(days_until_stock_out);
-- ============================================================
-- FETCH_LOG: Track data fetches
-- ============================================================
CREATE TABLE IF NOT EXISTS market_intel.fetch_log (
id SERIAL PRIMARY KEY,
fetch_type VARCHAR(50) NOT NULL, -- brands, dispensaries, products
state_code VARCHAR(10),
query_params JSONB,
records_fetched INTEGER DEFAULT 0,
records_inserted INTEGER DEFAULT 0,
records_updated INTEGER DEFAULT 0,
duration_ms INTEGER,
error_message TEXT,
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_fetch_log_type ON market_intel.fetch_log(fetch_type);
CREATE INDEX IF NOT EXISTS idx_fetch_log_state ON market_intel.fetch_log(state_code);
CREATE INDEX IF NOT EXISTS idx_fetch_log_started ON market_intel.fetch_log(started_at DESC);
-- ============================================================
-- HELPER VIEWS
-- ============================================================
-- Brand market presence summary
CREATE OR REPLACE VIEW market_intel.v_brand_presence AS
SELECT
b.brand_name,
b.parent_company,
b.active_variants,
b.all_variants,
jsonb_array_length(b.states) as state_count,
b.states,
b.fetched_at
FROM market_intel.brands b
ORDER BY b.active_variants DESC;
-- Dispensary sales rankings by state
CREATE OR REPLACE VIEW market_intel.v_dispensary_rankings AS
SELECT
d.dispensary_name,
d.city,
d.state,
d.banner,
d.business_type,
d.estimated_daily_sales,
d.menus_count,
d.is_medical,
d.is_recreational,
d.customer_segments,
RANK() OVER (PARTITION BY d.state ORDER BY d.estimated_daily_sales DESC NULLS LAST) as state_rank
FROM market_intel.dispensaries d
WHERE d.is_closed = FALSE;
-- Product distribution by brand and state
CREATE OR REPLACE VIEW market_intel.v_product_distribution AS
SELECT
p.brand,
p.d_state as state,
p.category_0 as category,
COUNT(*) as product_count,
COUNT(*) FILTER (WHERE p.in_stock) as in_stock_count,
AVG(p.dispensary_count) as avg_store_count,
COUNT(DISTINCT p.master_dispensary_id) as unique_stores
FROM market_intel.products p
GROUP BY p.brand, p.d_state, p.category_0;
-- ============================================================
-- COMMENTS
-- ============================================================
COMMENT ON SCHEMA market_intel IS 'Market intelligence data from external sources';
COMMENT ON TABLE market_intel.brands IS 'Brand/company data with multi-state presence';
COMMENT ON TABLE market_intel.dispensaries IS 'Dispensary data with sales estimates and demographics';
COMMENT ON TABLE market_intel.products IS 'Product/SKU data with cannabinoid and category info';
COMMENT ON TABLE market_intel.product_variants IS 'Variant-level pricing and stock data';
COMMENT ON TABLE market_intel.fetch_log IS 'Log of data fetches for monitoring';

View File

@@ -1,159 +0,0 @@
-- Migration 123: Extract unmapped fields from provider_data
-- These fields exist in our crawl payloads but weren't being stored in columns
-- ============================================================
-- ADD NEW COLUMNS TO store_products
-- ============================================================
-- Cannabis classification (SATIVA, INDICA, HYBRID, CBD)
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS cannabis_type VARCHAR(50);
-- Canonical IDs from POS systems
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_strain_id VARCHAR(100);
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_vendor_id VARCHAR(100);
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_brand_id VARCHAR(100);
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS canonical_category_id VARCHAR(100);
-- Lab results
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS lab_result_url TEXT;
-- Flavors (extracted from JSONB to text array for easier querying)
ALTER TABLE store_products ADD COLUMN IF NOT EXISTS flavors_list TEXT[];
-- ============================================================
-- BACKFILL FROM provider_data
-- ============================================================
-- Backfill cannabis_type from classification
UPDATE store_products
SET cannabis_type = CASE
WHEN provider_data->>'classification' IN ('HYBRID', 'H') THEN 'HYBRID'
WHEN provider_data->>'classification' IN ('INDICA', 'I') THEN 'INDICA'
WHEN provider_data->>'classification' IN ('SATIVA', 'S') THEN 'SATIVA'
WHEN provider_data->>'classification' = 'I/S' THEN 'INDICA_DOMINANT'
WHEN provider_data->>'classification' = 'S/I' THEN 'SATIVA_DOMINANT'
WHEN provider_data->>'classification' = 'CBD' THEN 'CBD'
ELSE provider_data->>'classification'
END
WHERE provider_data->>'classification' IS NOT NULL
AND cannabis_type IS NULL;
-- Also backfill from strain_type if cannabis_type still null
UPDATE store_products
SET cannabis_type = CASE
WHEN strain_type ILIKE '%indica%hybrid%' OR strain_type ILIKE '%hybrid%indica%' THEN 'INDICA_DOMINANT'
WHEN strain_type ILIKE '%sativa%hybrid%' OR strain_type ILIKE '%hybrid%sativa%' THEN 'SATIVA_DOMINANT'
WHEN strain_type ILIKE '%indica%' THEN 'INDICA'
WHEN strain_type ILIKE '%sativa%' THEN 'SATIVA'
WHEN strain_type ILIKE '%hybrid%' THEN 'HYBRID'
WHEN strain_type ILIKE '%cbd%' THEN 'CBD'
ELSE NULL
END
WHERE strain_type IS NOT NULL
AND cannabis_type IS NULL;
-- Backfill canonical IDs from POSMetaData
UPDATE store_products
SET
canonical_strain_id = provider_data->'POSMetaData'->>'canonicalStrainId',
canonical_vendor_id = provider_data->'POSMetaData'->>'canonicalVendorId',
canonical_brand_id = provider_data->'POSMetaData'->>'canonicalBrandId',
canonical_category_id = provider_data->'POSMetaData'->>'canonicalCategoryId'
WHERE provider_data->'POSMetaData' IS NOT NULL
AND canonical_strain_id IS NULL;
-- Backfill lab result URLs
UPDATE store_products
SET lab_result_url = provider_data->'POSMetaData'->>'canonicalLabResultUrl'
WHERE provider_data->'POSMetaData'->>'canonicalLabResultUrl' IS NOT NULL
AND lab_result_url IS NULL;
-- ============================================================
-- INDEXES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_store_products_cannabis_type ON store_products(cannabis_type);
CREATE INDEX IF NOT EXISTS idx_store_products_vendor_id ON store_products(canonical_vendor_id);
CREATE INDEX IF NOT EXISTS idx_store_products_strain_id ON store_products(canonical_strain_id);
-- ============================================================
-- ADD MSO FLAG TO DISPENSARIES
-- ============================================================
-- Multi-State Operator flag (calculated from chain presence in multiple states)
ALTER TABLE dispensaries ADD COLUMN IF NOT EXISTS is_mso BOOLEAN DEFAULT FALSE;
-- Update MSO flag based on chain presence in multiple states
WITH mso_chains AS (
SELECT chain_id
FROM dispensaries
WHERE chain_id IS NOT NULL
GROUP BY chain_id
HAVING COUNT(DISTINCT state) > 1
)
UPDATE dispensaries d
SET is_mso = TRUE
WHERE d.chain_id IN (SELECT chain_id FROM mso_chains);
-- Index for MSO queries
CREATE INDEX IF NOT EXISTS idx_dispensaries_is_mso ON dispensaries(is_mso) WHERE is_mso = TRUE;
-- ============================================================
-- PRODUCT DISTRIBUTION VIEW
-- ============================================================
-- View: How many stores carry each product (by brand + canonical name)
CREATE OR REPLACE VIEW v_product_distribution AS
SELECT
sp.brand_name_raw as brand,
sp.c_name as product_canonical_name,
COUNT(DISTINCT sp.dispensary_id) as store_count,
COUNT(DISTINCT d.state) as state_count,
ARRAY_AGG(DISTINCT d.state) as states,
AVG(sp.price_rec) as avg_price,
MIN(sp.price_rec) as min_price,
MAX(sp.price_rec) as max_price
FROM store_products sp
JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE sp.c_name IS NOT NULL
AND sp.brand_name_raw IS NOT NULL
AND sp.is_in_stock = TRUE
GROUP BY sp.brand_name_raw, sp.c_name
HAVING COUNT(DISTINCT sp.dispensary_id) > 1
ORDER BY store_count DESC;
-- ============================================================
-- MSO SUMMARY VIEW
-- ============================================================
CREATE OR REPLACE VIEW v_mso_summary AS
SELECT
c.name as chain_name,
COUNT(DISTINCT d.id) as store_count,
COUNT(DISTINCT d.state) as state_count,
ARRAY_AGG(DISTINCT d.state ORDER BY d.state) as states,
SUM(d.product_count) as total_products,
TRUE as is_mso
FROM dispensaries d
JOIN chains c ON c.id = d.chain_id
WHERE d.chain_id IN (
SELECT chain_id
FROM dispensaries
WHERE chain_id IS NOT NULL
GROUP BY chain_id
HAVING COUNT(DISTINCT state) > 1
)
GROUP BY c.id, c.name
ORDER BY state_count DESC, store_count DESC;
-- ============================================================
-- COMMENTS
-- ============================================================
COMMENT ON COLUMN store_products.cannabis_type IS 'Normalized cannabis classification: SATIVA, INDICA, HYBRID, INDICA_DOMINANT, SATIVA_DOMINANT, CBD';
COMMENT ON COLUMN store_products.canonical_strain_id IS 'POS system strain identifier for cross-store matching';
COMMENT ON COLUMN store_products.canonical_vendor_id IS 'POS system vendor/supplier identifier';
COMMENT ON COLUMN store_products.lab_result_url IS 'Link to Certificate of Analysis / lab test results';
COMMENT ON COLUMN dispensaries.is_mso IS 'Multi-State Operator: chain operates in 2+ states';
COMMENT ON VIEW v_product_distribution IS 'Shows how many stores carry each product for distribution analysis';
COMMENT ON VIEW v_mso_summary IS 'Summary of multi-state operator chains';

View File

@@ -1,73 +0,0 @@
-- Migration 124: Convert inventory_snapshots to TimescaleDB hypertable
-- Requires: CREATE EXTENSION timescaledb; (run after installing TimescaleDB)
-- ============================================================
-- STEP 1: Enable TimescaleDB extension
-- ============================================================
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- ============================================================
-- STEP 2: Convert to hypertable
-- ============================================================
-- Note: Table must have a time column and no foreign key constraints
-- First, drop any foreign keys if they exist
ALTER TABLE inventory_snapshots DROP CONSTRAINT IF EXISTS inventory_snapshots_dispensary_id_fkey;
-- Convert to hypertable, partitioned by captured_at (1 day chunks)
SELECT create_hypertable(
'inventory_snapshots',
'captured_at',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE,
migrate_data => TRUE
);
-- ============================================================
-- STEP 3: Enable compression
-- ============================================================
-- Compress by dispensary_id and product_id (common query patterns)
ALTER TABLE inventory_snapshots SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'dispensary_id, product_id',
timescaledb.compress_orderby = 'captured_at DESC'
);
-- ============================================================
-- STEP 4: Compression policy (compress chunks older than 1 day)
-- ============================================================
SELECT add_compression_policy('inventory_snapshots', INTERVAL '1 day');
-- ============================================================
-- STEP 5: Retention policy (optional - drop chunks older than 90 days)
-- ============================================================
-- Uncomment if you want automatic cleanup:
-- SELECT add_retention_policy('inventory_snapshots', INTERVAL '90 days');
-- ============================================================
-- STEP 6: Optimize indexes for time-series queries
-- ============================================================
-- TimescaleDB automatically creates time-based indexes
-- Add composite index for common queries
CREATE INDEX IF NOT EXISTS idx_snapshots_disp_prod_time
ON inventory_snapshots (dispensary_id, product_id, captured_at DESC);
-- ============================================================
-- VERIFICATION QUERIES (run after migration)
-- ============================================================
-- Check hypertable status:
-- SELECT * FROM timescaledb_information.hypertables WHERE hypertable_name = 'inventory_snapshots';
-- Check compression status:
-- SELECT * FROM timescaledb_information.compression_settings WHERE hypertable_name = 'inventory_snapshots';
-- Check chunk sizes:
-- SELECT chunk_name, pg_size_pretty(before_compression_total_bytes) as before,
-- pg_size_pretty(after_compression_total_bytes) as after,
-- round(100 - (after_compression_total_bytes::numeric / before_compression_total_bytes * 100), 1) as compression_pct
-- FROM chunk_compression_stats('inventory_snapshots');
-- ============================================================
-- COMMENTS
-- ============================================================
COMMENT ON TABLE inventory_snapshots IS 'TimescaleDB hypertable for inventory time-series data. Compressed after 1 day.';

View File

@@ -1,402 +0,0 @@
-- Migration 125: Delta-only inventory snapshots
-- Only store a row when something meaningful changes
-- Revenue calculated as: effective_price × qty_sold
-- ============================================================
-- ADD DELTA TRACKING COLUMNS
-- ============================================================
-- Previous values (to show what changed)
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_quantity INTEGER;
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_price_rec DECIMAL(10,2);
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_price_med DECIMAL(10,2);
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS prev_status VARCHAR(50);
-- Calculated deltas
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS qty_delta INTEGER; -- negative = sold, positive = restocked
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS price_delta DECIMAL(10,2);
-- Change type flags
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS change_type VARCHAR(50); -- 'sale', 'restock', 'price_change', 'oos', 'back_in_stock'
-- ============================================================
-- INDEX FOR CHANGE TYPE QUERIES
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_snapshots_change_type ON inventory_snapshots(change_type);
CREATE INDEX IF NOT EXISTS idx_snapshots_qty_delta ON inventory_snapshots(qty_delta) WHERE qty_delta != 0;
-- ============================================================
-- VIEW: Latest product state (for delta comparison)
-- ============================================================
CREATE OR REPLACE VIEW v_product_latest_state AS
SELECT DISTINCT ON (dispensary_id, product_id)
dispensary_id,
product_id,
quantity_available,
price_rec,
price_med,
status,
captured_at
FROM inventory_snapshots
ORDER BY dispensary_id, product_id, captured_at DESC;
-- ============================================================
-- FUNCTION: Check if product state changed
-- ============================================================
CREATE OR REPLACE FUNCTION should_capture_snapshot(
p_dispensary_id INTEGER,
p_product_id TEXT,
p_quantity INTEGER,
p_price_rec DECIMAL,
p_price_med DECIMAL,
p_status VARCHAR
) RETURNS TABLE (
should_capture BOOLEAN,
prev_quantity INTEGER,
prev_price_rec DECIMAL,
prev_price_med DECIMAL,
prev_status VARCHAR,
qty_delta INTEGER,
price_delta DECIMAL,
change_type VARCHAR
) AS $$
DECLARE
v_prev RECORD;
BEGIN
-- Get previous state
SELECT
ls.quantity_available,
ls.price_rec,
ls.price_med,
ls.status
INTO v_prev
FROM v_product_latest_state ls
WHERE ls.dispensary_id = p_dispensary_id
AND ls.product_id = p_product_id;
-- First time seeing this product
IF NOT FOUND THEN
RETURN QUERY SELECT
TRUE,
NULL::INTEGER,
NULL::DECIMAL,
NULL::DECIMAL,
NULL::VARCHAR,
NULL::INTEGER,
NULL::DECIMAL,
'new_product'::VARCHAR;
RETURN;
END IF;
-- Check for changes
IF v_prev.quantity_available IS DISTINCT FROM p_quantity
OR v_prev.price_rec IS DISTINCT FROM p_price_rec
OR v_prev.price_med IS DISTINCT FROM p_price_med
OR v_prev.status IS DISTINCT FROM p_status THEN
RETURN QUERY SELECT
TRUE,
v_prev.quantity_available,
v_prev.price_rec,
v_prev.price_med,
v_prev.status,
COALESCE(p_quantity, 0) - COALESCE(v_prev.quantity_available, 0),
COALESCE(p_price_rec, 0) - COALESCE(v_prev.price_rec, 0),
CASE
WHEN COALESCE(p_quantity, 0) < COALESCE(v_prev.quantity_available, 0) THEN 'sale'
WHEN COALESCE(p_quantity, 0) > COALESCE(v_prev.quantity_available, 0) THEN 'restock'
WHEN p_quantity = 0 AND v_prev.quantity_available > 0 THEN 'oos'
WHEN p_quantity > 0 AND v_prev.quantity_available = 0 THEN 'back_in_stock'
WHEN p_price_rec IS DISTINCT FROM v_prev.price_rec THEN 'price_change'
ELSE 'status_change'
END;
RETURN;
END IF;
-- No change
RETURN QUERY SELECT
FALSE,
NULL::INTEGER,
NULL::DECIMAL,
NULL::DECIMAL,
NULL::VARCHAR,
NULL::INTEGER,
NULL::DECIMAL,
NULL::VARCHAR;
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- REVENUE CALCULATION COLUMNS
-- ============================================================
-- Effective prices (sale price if on special, otherwise regular)
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS effective_price_rec DECIMAL(10,2);
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS effective_price_med DECIMAL(10,2);
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS is_on_special BOOLEAN DEFAULT FALSE;
-- Revenue by market type
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS revenue_rec DECIMAL(10,2);
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS revenue_med DECIMAL(10,2);
-- Time between snapshots (for velocity calc)
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS time_since_last_snapshot INTERVAL;
ALTER TABLE inventory_snapshots ADD COLUMN IF NOT EXISTS hours_since_last DECIMAL(10,2);
-- ============================================================
-- VIEW: Hourly Sales Velocity
-- ============================================================
CREATE OR REPLACE VIEW v_hourly_sales AS
SELECT
dispensary_id,
DATE(captured_at) as sale_date,
EXTRACT(HOUR FROM captured_at) as sale_hour,
COUNT(*) FILTER (WHERE qty_delta < 0) as transactions,
SUM(ABS(qty_delta)) FILTER (WHERE qty_delta < 0) as units_sold,
SUM(revenue_estimate) FILTER (WHERE qty_delta < 0) as revenue,
COUNT(DISTINCT product_id) FILTER (WHERE qty_delta < 0) as unique_products_sold
FROM inventory_snapshots
WHERE change_type = 'sale'
GROUP BY dispensary_id, DATE(captured_at), EXTRACT(HOUR FROM captured_at);
-- ============================================================
-- VIEW: Daily Sales by Store
-- ============================================================
CREATE OR REPLACE VIEW v_daily_store_sales AS
SELECT
s.dispensary_id,
d.name as store_name,
d.state,
DATE(s.captured_at) as sale_date,
SUM(ABS(s.qty_delta)) as units_sold,
SUM(s.revenue_estimate) as revenue,
COUNT(*) as sale_events,
COUNT(DISTINCT s.product_id) as unique_products
FROM inventory_snapshots s
JOIN dispensaries d ON d.id = s.dispensary_id
WHERE s.change_type = 'sale'
GROUP BY s.dispensary_id, d.name, d.state, DATE(s.captured_at);
-- ============================================================
-- VIEW: Daily Sales by Brand
-- ============================================================
CREATE OR REPLACE VIEW v_daily_brand_sales AS
SELECT
s.brand_name,
d.state,
DATE(s.captured_at) as sale_date,
SUM(ABS(s.qty_delta)) as units_sold,
SUM(s.revenue_estimate) as revenue,
COUNT(DISTINCT s.dispensary_id) as stores_with_sales,
COUNT(DISTINCT s.product_id) as unique_skus_sold
FROM inventory_snapshots s
JOIN dispensaries d ON d.id = s.dispensary_id
WHERE s.change_type = 'sale'
AND s.brand_name IS NOT NULL
GROUP BY s.brand_name, d.state, DATE(s.captured_at);
-- ============================================================
-- VIEW: Product Velocity Rankings
-- ============================================================
CREATE OR REPLACE VIEW v_product_velocity AS
SELECT
s.product_id,
s.brand_name,
s.category,
s.dispensary_id,
d.name as store_name,
d.state,
SUM(ABS(s.qty_delta)) as units_sold_30d,
SUM(s.revenue_estimate) as revenue_30d,
COUNT(*) as sale_events,
ROUND(SUM(ABS(s.qty_delta))::NUMERIC / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0), 2) as avg_daily_units,
ROUND(SUM(s.revenue_estimate) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0), 2) as avg_daily_revenue,
CASE
WHEN SUM(ABS(s.qty_delta)) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0) >= 10 THEN 'hot'
WHEN SUM(ABS(s.qty_delta)) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0) >= 3 THEN 'steady'
WHEN SUM(ABS(s.qty_delta)) / NULLIF(COUNT(DISTINCT DATE(s.captured_at)), 0) >= 1 THEN 'slow'
ELSE 'stale'
END as velocity_tier
FROM inventory_snapshots s
JOIN dispensaries d ON d.id = s.dispensary_id
WHERE s.change_type = 'sale'
AND s.captured_at >= NOW() - INTERVAL '30 days'
GROUP BY s.product_id, s.brand_name, s.category, s.dispensary_id, d.name, d.state;
-- ============================================================
-- VIEW: Busiest Hours by Store
-- ============================================================
CREATE OR REPLACE VIEW v_busiest_hours AS
SELECT
dispensary_id,
sale_hour,
AVG(units_sold) as avg_units_per_hour,
AVG(revenue) as avg_revenue_per_hour,
SUM(units_sold) as total_units,
SUM(revenue) as total_revenue,
COUNT(*) as days_with_data,
RANK() OVER (PARTITION BY dispensary_id ORDER BY AVG(revenue) DESC) as hour_rank
FROM v_hourly_sales
GROUP BY dispensary_id, sale_hour;
-- ============================================================
-- VIEW: Promotion Effectiveness (compare sale vs non-sale prices)
-- ============================================================
CREATE OR REPLACE VIEW v_promotion_effectiveness AS
SELECT
s.dispensary_id,
d.name as store_name,
s.product_id,
s.brand_name,
DATE(s.captured_at) as sale_date,
SUM(ABS(s.qty_delta)) FILTER (WHERE s.price_rec < s.prev_price_rec) as units_on_discount,
SUM(ABS(s.qty_delta)) FILTER (WHERE s.price_rec >= COALESCE(s.prev_price_rec, s.price_rec)) as units_full_price,
SUM(s.revenue_estimate) FILTER (WHERE s.price_rec < s.prev_price_rec) as revenue_discounted,
SUM(s.revenue_estimate) FILTER (WHERE s.price_rec >= COALESCE(s.prev_price_rec, s.price_rec)) as revenue_full_price
FROM inventory_snapshots s
JOIN dispensaries d ON d.id = s.dispensary_id
WHERE s.change_type = 'sale'
GROUP BY s.dispensary_id, d.name, s.product_id, s.brand_name, DATE(s.captured_at);
-- ============================================================
-- COMMENTS
-- ============================================================
COMMENT ON COLUMN inventory_snapshots.qty_delta IS 'Quantity change: negative=sold, positive=restocked';
COMMENT ON COLUMN inventory_snapshots.revenue_estimate IS 'Estimated revenue: ABS(qty_delta) * price_rec when qty_delta < 0';
COMMENT ON COLUMN inventory_snapshots.change_type IS 'Type of change: sale, restock, price_change, oos, back_in_stock, new_product';
COMMENT ON FUNCTION should_capture_snapshot IS 'Returns whether a snapshot should be captured and delta values';
COMMENT ON VIEW v_hourly_sales IS 'Sales aggregated by hour - find busiest times';
COMMENT ON VIEW v_daily_store_sales IS 'Daily revenue by store';
COMMENT ON VIEW v_daily_brand_sales IS 'Daily brand performance by state';
COMMENT ON VIEW v_product_velocity IS 'Product sales velocity rankings (hot/steady/slow/stale)';
COMMENT ON VIEW v_busiest_hours IS 'Rank hours by sales volume per store';
-- ============================================================
-- VIEW: Days Until Stock Out (Predictive)
-- ============================================================
CREATE OR REPLACE VIEW v_stock_out_prediction AS
WITH velocity AS (
SELECT
dispensary_id,
product_id,
brand_name,
-- Average units sold per day (last 7 days)
ROUND(SUM(ABS(qty_delta))::NUMERIC / NULLIF(COUNT(DISTINCT DATE(captured_at)), 0), 2) as daily_velocity,
-- Hours between sales
AVG(hours_since_last) FILTER (WHERE qty_delta < 0) as avg_hours_between_sales
FROM inventory_snapshots
WHERE change_type = 'sale'
AND captured_at >= NOW() - INTERVAL '7 days'
GROUP BY dispensary_id, product_id, brand_name
),
current_stock AS (
SELECT DISTINCT ON (dispensary_id, product_id)
dispensary_id,
product_id,
quantity_available as current_qty,
captured_at as last_seen
FROM inventory_snapshots
ORDER BY dispensary_id, product_id, captured_at DESC
)
SELECT
cs.dispensary_id,
d.name as store_name,
cs.product_id,
v.brand_name,
cs.current_qty,
v.daily_velocity,
CASE
WHEN v.daily_velocity > 0 THEN ROUND(cs.current_qty / v.daily_velocity, 1)
ELSE NULL
END as days_until_stock_out,
CASE
WHEN v.daily_velocity > 0 AND cs.current_qty / v.daily_velocity <= 3 THEN 'critical'
WHEN v.daily_velocity > 0 AND cs.current_qty / v.daily_velocity <= 7 THEN 'low'
WHEN v.daily_velocity > 0 AND cs.current_qty / v.daily_velocity <= 14 THEN 'moderate'
ELSE 'healthy'
END as stock_health,
cs.last_seen
FROM current_stock cs
JOIN velocity v ON v.dispensary_id = cs.dispensary_id AND v.product_id = cs.product_id
JOIN dispensaries d ON d.id = cs.dispensary_id
WHERE cs.current_qty > 0
AND v.daily_velocity > 0;
-- ============================================================
-- VIEW: Days Since OOS (for products currently out of stock)
-- ============================================================
CREATE OR REPLACE VIEW v_days_since_oos AS
SELECT
s.dispensary_id,
d.name as store_name,
s.product_id,
s.brand_name,
s.captured_at as went_oos_at,
EXTRACT(EPOCH FROM (NOW() - s.captured_at)) / 86400 as days_since_oos,
s.prev_quantity as last_known_qty
FROM inventory_snapshots s
JOIN dispensaries d ON d.id = s.dispensary_id
WHERE s.change_type = 'oos'
AND NOT EXISTS (
-- No back_in_stock event after this OOS
SELECT 1 FROM inventory_snapshots s2
WHERE s2.dispensary_id = s.dispensary_id
AND s2.product_id = s.product_id
AND s2.change_type = 'back_in_stock'
AND s2.captured_at > s.captured_at
);
-- ============================================================
-- VIEW: Brand Variant Counts (track brand growth)
-- ============================================================
CREATE OR REPLACE VIEW v_brand_variants AS
SELECT
sp.brand_name_raw as brand_name,
d.state,
COUNT(DISTINCT sp.id) as total_variants,
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_in_stock = TRUE) as active_variants,
COUNT(DISTINCT sp.id) FILTER (WHERE sp.is_in_stock = FALSE) as inactive_variants,
COUNT(DISTINCT sp.dispensary_id) as stores_carrying,
COUNT(DISTINCT sp.category_raw) as categories,
MIN(sp.first_seen_at) as brand_first_seen,
MAX(sp.last_seen_at) as brand_last_seen
FROM store_products sp
JOIN dispensaries d ON d.id = sp.dispensary_id
WHERE sp.brand_name_raw IS NOT NULL
GROUP BY sp.brand_name_raw, d.state;
-- ============================================================
-- VIEW: Brand Growth (compare variant counts over time)
-- ============================================================
CREATE OR REPLACE VIEW v_brand_growth AS
WITH weekly_counts AS (
SELECT
brand_name_raw as brand_name,
DATE_TRUNC('week', last_seen_at) as week,
COUNT(DISTINCT id) as variant_count
FROM store_products
WHERE brand_name_raw IS NOT NULL
AND last_seen_at >= NOW() - INTERVAL '90 days'
GROUP BY brand_name_raw, DATE_TRUNC('week', last_seen_at)
)
SELECT
w1.brand_name,
w1.week as current_week,
w1.variant_count as current_variants,
w2.variant_count as prev_week_variants,
w1.variant_count - COALESCE(w2.variant_count, 0) as variant_change,
CASE
WHEN w2.variant_count IS NULL THEN 'new'
WHEN w1.variant_count > w2.variant_count THEN 'growing'
WHEN w1.variant_count < w2.variant_count THEN 'declining'
ELSE 'stable'
END as growth_status
FROM weekly_counts w1
LEFT JOIN weekly_counts w2
ON w2.brand_name = w1.brand_name
AND w2.week = w1.week - INTERVAL '1 week'
ORDER BY w1.brand_name, w1.week DESC;
COMMENT ON VIEW v_stock_out_prediction IS 'Predict days until stock out based on velocity';
COMMENT ON VIEW v_days_since_oos IS 'Products currently OOS and how long they have been out';
COMMENT ON VIEW v_brand_variants IS 'Active vs inactive SKU counts per brand per state';
COMMENT ON VIEW v_brand_growth IS 'Week-over-week brand variant growth tracking';

View File

@@ -1,53 +0,0 @@
-- Migration 126: Set AZ stores to 5-minute high-frequency crawls
-- Other states default to 60-minute (1 hour) intervals
-- ============================================================
-- SET AZ STORES TO 5-MINUTE INTERVALS (with 3-min jitter)
-- ============================================================
-- Base interval: 5 minutes
-- Jitter: +/- 3 minutes (so 2-8 minute effective range)
UPDATE dispensaries
SET
crawl_interval_minutes = 5,
next_crawl_at = NOW() + (RANDOM() * INTERVAL '5 minutes') -- Stagger initial crawls
WHERE state = 'AZ'
AND crawl_enabled = TRUE;
-- ============================================================
-- SET OTHER STATES TO 60-MINUTE INTERVALS (with 3-min jitter)
-- ============================================================
UPDATE dispensaries
SET
crawl_interval_minutes = 60,
next_crawl_at = NOW() + (RANDOM() * INTERVAL '60 minutes') -- Stagger initial crawls
WHERE state != 'AZ'
AND crawl_enabled = TRUE
AND crawl_interval_minutes IS NULL;
-- ============================================================
-- VERIFY RESULTS
-- ============================================================
-- SELECT state, crawl_interval_minutes, COUNT(*)
-- FROM dispensaries
-- WHERE crawl_enabled = TRUE
-- GROUP BY state, crawl_interval_minutes
-- ORDER BY state;
-- ============================================================
-- CREATE VIEW FOR MONITORING CRAWL LOAD
-- ============================================================
CREATE OR REPLACE VIEW v_crawl_load AS
SELECT
state,
crawl_interval_minutes,
COUNT(*) as store_count,
-- Crawls per hour = stores * (60 / interval)
ROUND(COUNT(*) * (60.0 / COALESCE(crawl_interval_minutes, 60))) as crawls_per_hour,
-- Assuming 30 sec per crawl, workers needed = crawls_per_hour / 120
ROUND(COUNT(*) * (60.0 / COALESCE(crawl_interval_minutes, 60)) / 120, 1) as workers_needed
FROM dispensaries
WHERE crawl_enabled = TRUE
GROUP BY state, crawl_interval_minutes
ORDER BY crawls_per_hour DESC;
COMMENT ON VIEW v_crawl_load IS 'Monitor crawl load by state and interval';

View File

@@ -1,164 +0,0 @@
-- Migration 127: Fix worker task concurrency limit
-- Problem: claim_task function checks session_task_count but never increments it
-- Solution: Increment on claim, decrement on complete/fail/release
-- =============================================================================
-- STEP 1: Set max tasks to 5 for all workers
-- =============================================================================
UPDATE worker_registry SET session_max_tasks = 5;
-- Set default to 5 for new workers
ALTER TABLE worker_registry ALTER COLUMN session_max_tasks SET DEFAULT 5;
-- =============================================================================
-- STEP 2: Reset all session_task_count to match actual active tasks
-- =============================================================================
UPDATE worker_registry wr SET session_task_count = (
SELECT COUNT(*) FROM worker_tasks wt
WHERE wt.worker_id = wr.worker_id
AND wt.status IN ('claimed', 'running')
);
-- =============================================================================
-- STEP 3: Update claim_task function to increment session_task_count
-- =============================================================================
CREATE OR REPLACE FUNCTION claim_task(
p_role VARCHAR(50),
p_worker_id VARCHAR(100),
p_curl_passed BOOLEAN DEFAULT TRUE,
p_http_passed BOOLEAN DEFAULT FALSE
) RETURNS worker_tasks AS $$
DECLARE
claimed_task worker_tasks;
worker_state VARCHAR(2);
session_valid BOOLEAN;
session_tasks INT;
max_tasks INT;
BEGIN
-- Get worker's current geo session info
SELECT
current_state,
session_task_count,
session_max_tasks,
(geo_session_started_at IS NOT NULL AND geo_session_started_at > NOW() - INTERVAL '60 minutes')
INTO worker_state, session_tasks, max_tasks, session_valid
FROM worker_registry
WHERE worker_id = p_worker_id;
-- Check if worker has reached max concurrent tasks (default 5)
IF session_tasks >= COALESCE(max_tasks, 5) THEN
RETURN NULL;
END IF;
-- If no valid geo session, or session expired, worker can't claim tasks
-- Worker must re-qualify first
IF worker_state IS NULL OR NOT session_valid THEN
RETURN NULL;
END IF;
-- Claim task matching worker's state
UPDATE worker_tasks
SET
status = 'claimed',
worker_id = p_worker_id,
claimed_at = NOW(),
updated_at = NOW()
WHERE id = (
SELECT wt.id FROM worker_tasks wt
JOIN dispensaries d ON wt.dispensary_id = d.id
WHERE wt.role = p_role
AND wt.status = 'pending'
AND (wt.scheduled_for IS NULL OR wt.scheduled_for <= NOW())
-- GEO FILTER: Task's dispensary must match worker's state
AND d.state = worker_state
-- Method compatibility: worker must have passed the required preflight
AND (
wt.method IS NULL -- No preference, any worker can claim
OR (wt.method = 'curl' AND p_curl_passed = TRUE)
OR (wt.method = 'http' AND p_http_passed = TRUE)
)
-- Exclude stores that already have an active task
AND (wt.dispensary_id IS NULL OR wt.dispensary_id NOT IN (
SELECT dispensary_id FROM worker_tasks
WHERE status IN ('claimed', 'running')
AND dispensary_id IS NOT NULL
AND dispensary_id != wt.dispensary_id
))
ORDER BY wt.priority DESC, wt.created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING * INTO claimed_task;
-- INCREMENT session_task_count if we claimed a task
IF claimed_task.id IS NOT NULL THEN
UPDATE worker_registry
SET session_task_count = session_task_count + 1
WHERE worker_id = p_worker_id;
END IF;
RETURN claimed_task;
END;
$$ LANGUAGE plpgsql;
-- =============================================================================
-- STEP 4: Create trigger to decrement on task completion/failure/release
-- =============================================================================
CREATE OR REPLACE FUNCTION decrement_worker_task_count()
RETURNS TRIGGER AS $$
BEGIN
-- Only decrement when task was assigned to a worker and is now complete/released
IF OLD.worker_id IS NOT NULL AND OLD.status IN ('claimed', 'running') THEN
-- Task completed/failed/released - decrement count
IF NEW.status IN ('pending', 'completed', 'failed') OR NEW.worker_id IS NULL THEN
UPDATE worker_registry
SET session_task_count = GREATEST(0, session_task_count - 1)
WHERE worker_id = OLD.worker_id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop existing trigger if any
DROP TRIGGER IF EXISTS trg_decrement_worker_task_count ON worker_tasks;
-- Create trigger on UPDATE (status change or worker_id cleared)
CREATE TRIGGER trg_decrement_worker_task_count
AFTER UPDATE ON worker_tasks
FOR EACH ROW
EXECUTE FUNCTION decrement_worker_task_count();
-- Also handle DELETE (completed tasks are deleted from pool)
CREATE OR REPLACE FUNCTION decrement_worker_task_count_delete()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.worker_id IS NOT NULL AND OLD.status IN ('claimed', 'running') THEN
UPDATE worker_registry
SET session_task_count = GREATEST(0, session_task_count - 1)
WHERE worker_id = OLD.worker_id;
END IF;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_decrement_worker_task_count_delete ON worker_tasks;
CREATE TRIGGER trg_decrement_worker_task_count_delete
AFTER DELETE ON worker_tasks
FOR EACH ROW
EXECUTE FUNCTION decrement_worker_task_count_delete();
-- =============================================================================
-- STEP 5: Verify current state
-- =============================================================================
SELECT
wr.worker_id,
wr.friendly_name,
wr.session_task_count,
wr.session_max_tasks,
(SELECT COUNT(*) FROM worker_tasks wt WHERE wt.worker_id = wr.worker_id AND wt.status IN ('claimed', 'running')) as actual_count
FROM worker_registry wr
WHERE wr.status = 'active'
ORDER BY wr.friendly_name;

View File

@@ -1,109 +0,0 @@
-- Migration 128: Pool configuration table
-- Controls whether workers can claim tasks from the pool
CREATE TABLE IF NOT EXISTS pool_config (
id SERIAL PRIMARY KEY,
pool_open BOOLEAN NOT NULL DEFAULT true,
closed_reason TEXT,
closed_at TIMESTAMPTZ,
closed_by VARCHAR(100),
opened_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Insert default config (pool open)
INSERT INTO pool_config (pool_open, opened_at)
VALUES (true, NOW())
ON CONFLICT DO NOTHING;
-- Update claim_task function to check pool status
CREATE OR REPLACE FUNCTION claim_task(
p_role VARCHAR(50),
p_worker_id VARCHAR(100),
p_curl_passed BOOLEAN DEFAULT TRUE,
p_http_passed BOOLEAN DEFAULT FALSE
) RETURNS worker_tasks AS $$
DECLARE
claimed_task worker_tasks;
worker_state VARCHAR(2);
session_valid BOOLEAN;
session_tasks INT;
max_tasks INT;
is_pool_open BOOLEAN;
BEGIN
-- Check if pool is open
SELECT pool_open INTO is_pool_open FROM pool_config LIMIT 1;
IF NOT COALESCE(is_pool_open, true) THEN
RETURN NULL; -- Pool is closed, no claiming allowed
END IF;
-- Get worker's current geo session info
SELECT
current_state,
session_task_count,
session_max_tasks,
(geo_session_started_at IS NOT NULL AND geo_session_started_at > NOW() - INTERVAL '60 minutes')
INTO worker_state, session_tasks, max_tasks, session_valid
FROM worker_registry
WHERE worker_id = p_worker_id;
-- Check if worker has reached max concurrent tasks (default 5)
IF session_tasks >= COALESCE(max_tasks, 5) THEN
RETURN NULL;
END IF;
-- If no valid geo session, or session expired, worker can't claim tasks
-- Worker must re-qualify first
IF worker_state IS NULL OR NOT session_valid THEN
RETURN NULL;
END IF;
-- Claim task matching worker's state
UPDATE worker_tasks
SET
status = 'claimed',
worker_id = p_worker_id,
claimed_at = NOW(),
updated_at = NOW()
WHERE id = (
SELECT wt.id FROM worker_tasks wt
JOIN dispensaries d ON wt.dispensary_id = d.id
WHERE wt.role = p_role
AND wt.status = 'pending'
AND (wt.scheduled_for IS NULL OR wt.scheduled_for <= NOW())
-- GEO FILTER: Task's dispensary must match worker's state
AND d.state = worker_state
-- Method compatibility: worker must have passed the required preflight
AND (
wt.method IS NULL -- No preference, any worker can claim
OR (wt.method = 'curl' AND p_curl_passed = TRUE)
OR (wt.method = 'http' AND p_http_passed = TRUE)
)
-- Exclude stores that already have an active task
AND (wt.dispensary_id IS NULL OR wt.dispensary_id NOT IN (
SELECT dispensary_id FROM worker_tasks
WHERE status IN ('claimed', 'running')
AND dispensary_id IS NOT NULL
AND dispensary_id != wt.dispensary_id
))
ORDER BY wt.priority DESC, wt.created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING * INTO claimed_task;
-- INCREMENT session_task_count if we claimed a task
IF claimed_task.id IS NOT NULL THEN
UPDATE worker_registry
SET session_task_count = session_task_count + 1
WHERE worker_id = p_worker_id;
END IF;
RETURN claimed_task;
END;
$$ LANGUAGE plpgsql;
-- Verify
SELECT 'pool_config table created' as status;
SELECT * FROM pool_config;

View File

@@ -1,60 +0,0 @@
-- Migration 129: Claim tasks for specific geo
-- Used after worker gets IP to claim more tasks for same geo
-- Function: Claim up to N tasks for a SPECIFIC geo (state/city)
-- Different from claim_tasks_batch which picks the geo with most tasks
CREATE OR REPLACE FUNCTION claim_tasks_batch_for_geo(
p_worker_id VARCHAR(255),
p_max_tasks INTEGER DEFAULT 4,
p_state_code VARCHAR(2),
p_city VARCHAR(100) DEFAULT NULL,
p_role VARCHAR(50) DEFAULT NULL
) RETURNS TABLE (
task_id INTEGER,
role VARCHAR(50),
dispensary_id INTEGER,
dispensary_name VARCHAR(255),
city VARCHAR(100),
state_code VARCHAR(2),
platform VARCHAR(50),
method VARCHAR(20)
) AS $$
BEGIN
-- Claim up to p_max_tasks for the specified geo
RETURN QUERY
WITH claimed AS (
UPDATE worker_tasks t SET
status = 'claimed',
worker_id = p_worker_id,
claimed_at = NOW()
FROM (
SELECT t2.id
FROM worker_tasks t2
JOIN dispensaries d ON t2.dispensary_id = d.id
WHERE t2.status = 'pending'
AND d.state = p_state_code
AND (p_city IS NULL OR d.city = p_city)
AND (p_role IS NULL OR t2.role = p_role)
ORDER BY t2.priority DESC, t2.created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT p_max_tasks
) sub
WHERE t.id = sub.id
RETURNING t.id, t.role, t.dispensary_id, t.method
)
SELECT
c.id as task_id,
c.role,
c.dispensary_id,
d.name as dispensary_name,
d.city,
d.state as state_code,
d.platform,
c.method
FROM claimed c
JOIN dispensaries d ON c.dispensary_id = d.id;
END;
$$ LANGUAGE plpgsql;
-- Verify
SELECT 'claim_tasks_batch_for_geo function created' as status;

View File

@@ -1,53 +0,0 @@
-- Migration 130: Worker qualification badge
-- Session-scoped badge showing worker qualification status
-- Add badge column to worker_registry
ALTER TABLE worker_registry
ADD COLUMN IF NOT EXISTS badge VARCHAR(20) DEFAULT NULL;
-- Add qualified_at timestamp
ALTER TABLE worker_registry
ADD COLUMN IF NOT EXISTS qualified_at TIMESTAMPTZ DEFAULT NULL;
-- Add current_session_id to link worker to their active session
ALTER TABLE worker_registry
ADD COLUMN IF NOT EXISTS current_session_id INTEGER DEFAULT NULL;
-- Badge values:
-- 'gold' = preflight passed, actively qualified with valid session
-- NULL = not qualified (no active session or session expired)
-- Function: Set worker badge to gold when qualified
CREATE OR REPLACE FUNCTION set_worker_qualified(
p_worker_id VARCHAR(255),
p_session_id INTEGER
) RETURNS BOOLEAN AS $$
BEGIN
UPDATE worker_registry
SET badge = 'gold',
qualified_at = NOW(),
current_session_id = p_session_id
WHERE worker_id = p_worker_id;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
-- Function: Clear worker badge when session ends
CREATE OR REPLACE FUNCTION clear_worker_badge(p_worker_id VARCHAR(255))
RETURNS BOOLEAN AS $$
BEGIN
UPDATE worker_registry
SET badge = NULL,
qualified_at = NULL,
current_session_id = NULL
WHERE worker_id = p_worker_id;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql;
-- Index for finding qualified workers
CREATE INDEX IF NOT EXISTS idx_worker_registry_badge
ON worker_registry(badge) WHERE badge IS NOT NULL;
-- Verify
SELECT 'worker_registry badge column added' as status;

1784
backend/node_modules/.package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

1550
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@
"seed:dt:cities:bulk": "tsx src/scripts/seed-dt-cities-bulk.ts" "seed:dt:cities:bulk": "tsx src/scripts/seed-dt-cities-bulk.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.953.0",
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"axios": "^1.6.2", "axios": "^1.6.2",

View File

@@ -1 +1 @@
cannaiq-menus-2.3.0.zip cannaiq-menus-1.7.0.zip

View File

@@ -151,6 +151,18 @@ function generateSlug(name: string, city: string, state: string): string {
return base; return base;
} }
/**
* Derive menu_type from platform_menu_url pattern
*/
function deriveMenuType(url: string | null): string {
if (!url) return 'unknown';
if (url.includes('/dispensary/')) return 'standalone';
if (url.includes('/embedded-menu/')) return 'embedded';
if (url.includes('/stores/')) return 'standalone';
// Custom domain = embedded widget on store's site
if (!url.includes('dutchie.com')) return 'embedded';
return 'unknown';
}
/** /**
* Log a promotion action to dutchie_promotion_log * Log a promotion action to dutchie_promotion_log
@@ -403,7 +415,7 @@ async function promoteLocation(
loc.timezone, // $15 timezone loc.timezone, // $15 timezone
loc.platform_location_id, // $16 platform_dispensary_id loc.platform_location_id, // $16 platform_dispensary_id
loc.platform_menu_url, // $17 menu_url loc.platform_menu_url, // $17 menu_url
'dutchie', // $18 menu_type deriveMenuType(loc.platform_menu_url), // $18 menu_type
loc.description, // $19 description loc.description, // $19 description
loc.logo_image, // $20 logo_image loc.logo_image, // $20 logo_image
loc.banner_image, // $21 banner_image loc.banner_image, // $21 banner_image

View File

@@ -124,7 +124,6 @@ import workerRegistryRoutes from './routes/worker-registry';
// Per TASK_WORKFLOW_2024-12-10.md: Raw payload access API // Per TASK_WORKFLOW_2024-12-10.md: Raw payload access API
import payloadsRoutes from './routes/payloads'; import payloadsRoutes from './routes/payloads';
import k8sRoutes from './routes/k8s'; import k8sRoutes from './routes/k8s';
import poolRoutes from './routes/pool';
// Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com) // Mark requests from trusted domains (cannaiq.co, findagram.co, findadispo.com)
@@ -222,10 +221,6 @@ console.log('[Payloads] Routes registered at /api/payloads');
app.use('/api/k8s', k8sRoutes); app.use('/api/k8s', k8sRoutes);
console.log('[K8s] Routes registered at /api/k8s'); console.log('[K8s] Routes registered at /api/k8s');
// Pool control routes - open/close pool, manage tasks
app.use('/api/pool', poolRoutes);
console.log('[Pool] Routes registered at /api/pool');
// Phase 3: Analytics V2 - Enhanced analytics with rec/med state segmentation // Phase 3: Analytics V2 - Enhanced analytics with rec/med state segmentation
try { try {
const analyticsV2Router = createAnalyticsV2Router(getPool()); const analyticsV2Router = createAnalyticsV2Router(getPool());
@@ -271,11 +266,6 @@ console.log('[ClickAnalytics] Routes registered at /api/analytics/clicks');
app.use('/api/analytics/price', priceAnalyticsRoutes); app.use('/api/analytics/price', priceAnalyticsRoutes);
console.log('[PriceAnalytics] Routes registered at /api/analytics/price'); console.log('[PriceAnalytics] Routes registered at /api/analytics/price');
// Sales Analytics API - sales velocity, brand market share, product intelligence
import salesAnalyticsRoutes from './routes/sales-analytics';
app.use('/api/sales-analytics', salesAnalyticsRoutes);
console.log('[SalesAnalytics] Routes registered at /api/sales-analytics');
// States API routes - cannabis legalization status and targeting // States API routes - cannabis legalization status and targeting
try { try {
const statesRouter = createStatesRouter(getPool()); const statesRouter = createStatesRouter(getPool());

View File

@@ -289,160 +289,6 @@ export function getStoreConfig(): TreezStoreConfig | null {
return currentStoreConfig; return currentStoreConfig;
} }
/**
* Extract store config from page HTML for SSR sites.
*
* SSR sites (like BEST Dispensary) pre-render data and don't make client-side
* API requests. The config is embedded in __NEXT_DATA__ or window variables.
*
* Looks for:
* - __NEXT_DATA__.props.pageProps.msoStoreConfig.orgId / entityId
* - window.__SETTINGS__.msoOrgId / msoStoreEntityId
* - treezStores config in page data
*/
async function extractConfigFromPage(page: Page): Promise<TreezStoreConfig | null> {
console.log('[Treez Client] Attempting to extract config from page HTML (SSR fallback)...');
const config = await page.evaluate(() => {
// Try __NEXT_DATA__ first (Next.js SSR)
const nextDataEl = document.getElementById('__NEXT_DATA__');
if (nextDataEl) {
try {
const nextData = JSON.parse(nextDataEl.textContent || '{}');
const pageProps = nextData?.props?.pageProps;
// Look for MSO config in various locations
const msoConfig = pageProps?.msoStoreConfig || pageProps?.storeConfig || {};
const settings = pageProps?.settings || {};
// Extract org-id and entity-id
let orgId = msoConfig.orgId || msoConfig.msoOrgId || settings.msoOrgId;
let entityId = msoConfig.entityId || msoConfig.msoStoreEntityId || settings.msoStoreEntityId;
// Also check treezStores array
if (!orgId || !entityId) {
const treezStores = pageProps?.treezStores || nextData?.props?.treezStores;
if (treezStores && Array.isArray(treezStores) && treezStores.length > 0) {
const store = treezStores[0];
orgId = orgId || store.orgId || store.organization_id;
entityId = entityId || store.entityId || store.entity_id || store.storeId;
}
}
// Check for API settings
const apiSettings = pageProps?.apiSettings || settings.api || {};
if (orgId && entityId) {
return {
orgId,
entityId,
esUrl: apiSettings.esUrl || null,
apiKey: apiSettings.apiKey || null,
};
}
} catch (e) {
console.error('Error parsing __NEXT_DATA__:', e);
}
}
// Try window variables
const win = window as any;
if (win.__SETTINGS__) {
const s = win.__SETTINGS__;
if (s.msoOrgId && s.msoStoreEntityId) {
return {
orgId: s.msoOrgId,
entityId: s.msoStoreEntityId,
esUrl: s.esUrl || null,
apiKey: s.apiKey || null,
};
}
}
// Try Next.js App Router streaming format (self.__next_f.push)
// This format is used by newer Next.js sites like BEST Dispensary
try {
const scripts = Array.from(document.querySelectorAll('script'));
for (const script of scripts) {
const content = script.textContent || '';
if (content.includes('self.__next_f.push') && content.includes('"Treez"')) {
// Extract JSON data from the streaming format
// Format: self.__next_f.push([1,"...escaped json..."])
const matches = content.match(/self\.__next_f\.push\(\[1,"(.+?)"\]\)/g);
if (matches) {
for (const match of matches) {
try {
// Extract the JSON string and unescape it
const jsonMatch = match.match(/self\.__next_f\.push\(\[1,"(.+?)"\]\)/);
if (jsonMatch && jsonMatch[1]) {
// Unescape the JSON string
const unescaped = jsonMatch[1]
.replace(/\\"/g, '"')
.replace(/\\n/g, '\n')
.replace(/\\\\/g, '\\');
// Look for Treez credentials in the data
// Pattern: "apps":[{"name":"Treez","credentials":[{"store_id":"..."}]}]
const treezMatch = unescaped.match(/"apps":\s*\[([\s\S]*?)\]/);
if (treezMatch) {
const appsStr = '[' + treezMatch[1] + ']';
try {
const apps = JSON.parse(appsStr);
const treezApp = apps.find((a: any) => a.name === 'Treez' || a.handler === 'treez');
if (treezApp && treezApp.credentials && treezApp.credentials.length > 0) {
const cred = treezApp.credentials[0];
if (cred.store_id) {
console.log('[Treez Client] Found config in App Router streaming data');
return {
orgId: cred.headless_client_id || null,
entityId: cred.store_id,
esUrl: null,
apiKey: cred.headless_client_secret || null,
};
}
}
} catch (e) {
// Continue searching
}
}
}
} catch (e) {
// Continue to next match
}
}
}
}
}
} catch (e) {
console.error('Error parsing App Router streaming data:', e);
}
return null;
});
if (!config || !config.orgId || !config.entityId) {
console.log('[Treez Client] Could not extract config from page');
return null;
}
// Build full config with defaults for missing values
const fullConfig: TreezStoreConfig = {
orgId: config.orgId,
entityId: config.entityId,
// Default ES URL pattern - gapcommerce is the common tenant
esUrl: config.esUrl || 'https://search-gapcommerce.gapcommerceapi.com/product/search',
// Use default API key from config
apiKey: config.apiKey || TREEZ_CONFIG.esApiKey,
};
console.log('[Treez Client] Extracted config from page (SSR):');
console.log(` ES URL: ${fullConfig.esUrl}`);
console.log(` Org ID: ${fullConfig.orgId}`);
console.log(` Entity ID: ${fullConfig.entityId}`);
return fullConfig;
}
// ============================================================ // ============================================================
// PRODUCT FETCHING (Direct API Approach) // PRODUCT FETCHING (Direct API Approach)
// ============================================================ // ============================================================
@@ -497,15 +343,9 @@ export async function fetchAllProducts(
// Wait for initial page load to trigger first API request // Wait for initial page load to trigger first API request
await sleep(3000); await sleep(3000);
// Check if we captured the store config from network requests // Check if we captured the store config
if (!currentStoreConfig) { if (!currentStoreConfig) {
console.log('[Treez Client] No API requests captured - trying SSR fallback...'); console.error('[Treez Client] Failed to capture store config from browser requests');
// For SSR sites, extract config from page HTML
currentStoreConfig = await extractConfigFromPage(page);
}
if (!currentStoreConfig) {
console.error('[Treez Client] Failed to capture store config from browser requests or page HTML');
throw new Error('Failed to capture Treez store config'); throw new Error('Failed to capture Treez store config');
} }

View File

@@ -16,82 +16,7 @@ import { authMiddleware } from '../auth/middleware';
const router = Router(); const router = Router();
/** // All click analytics endpoints require authentication
* POST /api/analytics/click
* Record a click event from WordPress plugin
* This endpoint is public but requires API token in Authorization header
*/
router.post('/click', async (req: Request, res: Response) => {
try {
// Get API token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing API token' });
}
const apiToken = authHeader.substring(7);
// Validate API token and get store_id
const tokenResult = await pool.query(
'SELECT store_id FROM api_tokens WHERE token = $1 AND is_active = true',
[apiToken]
);
if (tokenResult.rows.length === 0) {
return res.status(401).json({ error: 'Invalid API token' });
}
const tokenStoreId = tokenResult.rows[0].store_id;
const {
event_type,
store_id,
product_id,
product_name,
product_price,
category,
url,
referrer,
timestamp
} = req.body;
// Use store_id from token if not provided in request
const finalStoreId = store_id || tokenStoreId;
// Insert click event
await pool.query(`
INSERT INTO product_click_events (
store_id,
product_id,
brand_id,
action,
metadata,
occurred_at
) VALUES ($1, $2, $3, $4, $5, $6)
`, [
finalStoreId,
product_id || null,
null, // brand_id will be looked up later if needed
event_type || 'click',
JSON.stringify({
product_name,
product_price,
category,
url,
referrer,
source: 'wordpress_plugin'
}),
timestamp || new Date().toISOString()
]);
res.json({ success: true });
} catch (error: any) {
console.error('[ClickAnalytics] Error recording click:', error.message);
res.status(500).json({ error: 'Failed to record click' });
}
});
// All other click analytics endpoints require authentication
router.use(authMiddleware); router.use(authMiddleware);
/** /**

View File

@@ -137,72 +137,4 @@ router.post('/workers/scale', async (req: Request, res: Response) => {
} }
}); });
/**
* POST /api/k8s/workers/restart
* Rolling restart of worker deployment
* Triggers a new rollout by updating an annotation
*/
router.post('/workers/restart', async (_req: Request, res: Response) => {
const client = getK8sClient();
if (!client) {
return res.status(503).json({
success: false,
error: k8sError || 'K8s not available',
});
}
try {
// Trigger rolling restart by scaling down then up
// This is simpler than patching annotations and works reliably
const now = new Date().toISOString();
// Get current replicas
const deployment = await client.readNamespacedDeployment({
name: WORKER_DEPLOYMENT,
namespace: NAMESPACE,
});
const currentReplicas = deployment.spec?.replicas || 0;
if (currentReplicas === 0) {
return res.status(400).json({
success: false,
error: 'Deployment has 0 replicas - cannot restart',
});
}
// Scale to 0 then back up to trigger restart
await client.patchNamespacedDeploymentScale({
name: WORKER_DEPLOYMENT,
namespace: NAMESPACE,
body: { spec: { replicas: 0 } },
});
// Brief delay then scale back up
await new Promise(resolve => setTimeout(resolve, 2000));
await client.patchNamespacedDeploymentScale({
name: WORKER_DEPLOYMENT,
namespace: NAMESPACE,
body: { spec: { replicas: currentReplicas } },
});
console.log(`[K8s] Triggered restart of ${WORKER_DEPLOYMENT} (${currentReplicas} replicas)`);
res.json({
success: true,
message: `Restarted ${currentReplicas} workers`,
replicas: currentReplicas,
restartedAt: now,
});
} catch (e: any) {
console.error('[K8s] Error restarting deployment:', e.message);
res.status(500).json({
success: false,
error: e.message,
});
}
});
export default router; export default router;

View File

@@ -1,371 +0,0 @@
/**
* Task Pool Control Routes
*
* Provides admin control over the task pool:
* - Open/close pool (enable/disable task claiming)
* - View pool status and statistics
* - Clear pending tasks
*/
import { Router, Request, Response } from 'express';
import { pool } from '../db/pool';
const router = Router();
/**
* GET /api/pool/status
* Get current pool status and statistics
*/
router.get('/status', async (_req: Request, res: Response) => {
try {
// Get pool config
const configResult = await pool.query(`
SELECT pool_open, closed_reason, closed_at, closed_by
FROM pool_config
LIMIT 1
`);
const config = configResult.rows[0] || { pool_open: true };
// Get task counts by status
const taskStats = await pool.query(`
SELECT
status,
COUNT(*) as count
FROM worker_tasks
GROUP BY status
`);
const statusCounts: Record<string, number> = {};
for (const row of taskStats.rows) {
statusCounts[row.status] = parseInt(row.count);
}
// Get pending tasks by role
const pendingByRole = await pool.query(`
SELECT
role,
COUNT(*) as count
FROM worker_tasks
WHERE status = 'pending'
GROUP BY role
ORDER BY count DESC
`);
// Get pending tasks by state
const pendingByState = await pool.query(`
SELECT
d.state,
COUNT(*) as count
FROM worker_tasks wt
JOIN dispensaries d ON d.id = wt.dispensary_id
WHERE wt.status = 'pending'
GROUP BY d.state
ORDER BY count DESC
`);
// Get active workers count
const activeWorkers = await pool.query(`
SELECT COUNT(*) as count
FROM worker_registry
WHERE status = 'active'
AND last_heartbeat_at > NOW() - INTERVAL '2 minutes'
`);
res.json({
success: true,
pool_open: config.pool_open,
closed_reason: config.closed_reason,
closed_at: config.closed_at,
closed_by: config.closed_by,
stats: {
total_pending: statusCounts['pending'] || 0,
total_claimed: statusCounts['claimed'] || 0,
total_running: statusCounts['running'] || 0,
total_completed: statusCounts['completed'] || 0,
total_failed: statusCounts['failed'] || 0,
active_workers: parseInt(activeWorkers.rows[0].count),
},
pending_by_role: pendingByRole.rows,
pending_by_state: pendingByState.rows,
});
} catch (error: any) {
console.error('[Pool] Status error:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/pool/open
* Open the pool (allow task claiming)
*/
router.post('/open', async (_req: Request, res: Response) => {
try {
await pool.query(`
UPDATE pool_config
SET pool_open = true,
closed_reason = NULL,
closed_at = NULL,
closed_by = NULL,
opened_at = NOW()
`);
console.log('[Pool] Pool opened - workers can claim tasks');
res.json({
success: true,
pool_open: true,
message: 'Pool is now open - workers can claim tasks',
});
} catch (error: any) {
console.error('[Pool] Open error:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/pool/close
* Close the pool (stop task claiming)
* Body: { reason?: string }
*/
router.post('/close', async (req: Request, res: Response) => {
try {
const { reason } = req.body;
await pool.query(`
UPDATE pool_config
SET pool_open = false,
closed_reason = $1,
closed_at = NOW(),
closed_by = 'admin'
`, [reason || 'Manually closed']);
console.log(`[Pool] Pool closed - reason: ${reason || 'Manually closed'}`);
res.json({
success: true,
pool_open: false,
message: 'Pool is now closed - workers cannot claim new tasks',
reason: reason || 'Manually closed',
});
} catch (error: any) {
console.error('[Pool] Close error:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/pool/clear
* Clear pending tasks (optionally by role or state)
* Body: { role?: string, state?: string, confirm: boolean }
*/
router.post('/clear', async (req: Request, res: Response) => {
try {
const { role, state, confirm } = req.body;
if (!confirm) {
return res.status(400).json({
success: false,
error: 'Must set confirm: true to clear tasks',
});
}
let whereClause = "WHERE status = 'pending'";
const params: any[] = [];
let paramIndex = 1;
if (role) {
whereClause += ` AND role = $${paramIndex++}`;
params.push(role);
}
if (state) {
whereClause += ` AND dispensary_id IN (SELECT id FROM dispensaries WHERE state = $${paramIndex++})`;
params.push(state);
}
// Count first
const countResult = await pool.query(
`SELECT COUNT(*) as count FROM worker_tasks ${whereClause}`,
params
);
const taskCount = parseInt(countResult.rows[0].count);
if (taskCount === 0) {
return res.json({
success: true,
cleared: 0,
message: 'No matching pending tasks found',
});
}
// Delete pending tasks
await pool.query(`DELETE FROM worker_tasks ${whereClause}`, params);
const filterDesc = [
role ? `role=${role}` : null,
state ? `state=${state}` : null,
].filter(Boolean).join(', ') || 'all';
console.log(`[Pool] Cleared ${taskCount} pending tasks (${filterDesc})`);
res.json({
success: true,
cleared: taskCount,
message: `Cleared ${taskCount} pending tasks`,
filter: { role, state },
});
} catch (error: any) {
console.error('[Pool] Clear error:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/pool/tasks
* List tasks in pool with pagination
* Query: { status?, role?, state?, limit?, offset? }
*/
router.get('/tasks', async (req: Request, res: Response) => {
try {
const {
status = 'pending',
role,
state,
limit = '50',
offset = '0',
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (status && status !== 'all') {
conditions.push(`wt.status = $${paramIndex++}`);
params.push(status);
}
if (role) {
conditions.push(`wt.role = $${paramIndex++}`);
params.push(role);
}
if (state) {
conditions.push(`d.state = $${paramIndex++}`);
params.push(state);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(parseInt(limit as string), parseInt(offset as string));
const result = await pool.query(`
SELECT
wt.id,
wt.role,
wt.platform,
wt.status,
wt.priority,
wt.method,
wt.worker_id,
wt.created_at,
wt.scheduled_for,
wt.claimed_at,
wt.started_at,
wt.completed_at,
wt.error_message,
d.id as dispensary_id,
d.name as dispensary_name,
d.city as dispensary_city,
d.state as dispensary_state
FROM worker_tasks wt
LEFT JOIN dispensaries d ON d.id = wt.dispensary_id
${whereClause}
ORDER BY wt.priority DESC, wt.created_at ASC
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, params);
res.json({
success: true,
tasks: result.rows,
pagination: {
limit: parseInt(limit as string),
offset: parseInt(offset as string),
returned: result.rows.length,
},
});
} catch (error: any) {
console.error('[Pool] Tasks error:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* DELETE /api/pool/tasks/:taskId
* Remove a specific task from the pool
*/
router.delete('/tasks/:taskId', async (req: Request, res: Response) => {
try {
const { taskId } = req.params;
const result = await pool.query(`
DELETE FROM worker_tasks
WHERE id = $1 AND status = 'pending'
RETURNING id, role, dispensary_id
`, [taskId]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Task not found or not in pending status',
});
}
res.json({
success: true,
deleted: result.rows[0],
message: `Task #${taskId} removed from pool`,
});
} catch (error: any) {
console.error('[Pool] Delete task error:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/pool/release-stale
* Release tasks that have been claimed/running too long
* Body: { stale_minutes?: number (default: 30) }
*/
router.post('/release-stale', async (req: Request, res: Response) => {
try {
const { stale_minutes = 30 } = req.body;
const result = await pool.query(`
UPDATE worker_tasks
SET status = 'pending',
worker_id = NULL,
claimed_at = NULL,
started_at = NULL,
error_message = 'Released due to stale timeout'
WHERE status IN ('claimed', 'running')
AND (claimed_at < NOW() - INTERVAL '1 minute' * $1
OR started_at < NOW() - INTERVAL '1 minute' * $1)
RETURNING id, role, dispensary_id, worker_id
`, [stale_minutes]);
console.log(`[Pool] Released ${result.rows.length} stale tasks`);
res.json({
success: true,
released: result.rows.length,
tasks: result.rows,
message: `Released ${result.rows.length} tasks older than ${stale_minutes} minutes`,
});
} catch (error: any) {
console.error('[Pool] Release stale error:', error.message);
res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@@ -532,7 +532,6 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
// Query products with latest snapshot data // Query products with latest snapshot data
// Uses store_products + v_product_snapshots (canonical tables with raw_data) // Uses store_products + v_product_snapshots (canonical tables with raw_data)
// Join dispensaries to get menu_url for cart links
const { rows: products } = await pool.query(` const { rows: products } = await pool.query(`
SELECT SELECT
p.id, p.id,
@@ -556,10 +555,8 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
s.stock_quantity as total_quantity_available, s.stock_quantity as total_quantity_available,
s.special, s.special,
s.crawled_at as snapshot_at, s.crawled_at as snapshot_at,
d.menu_url as dispensary_menu_url,
${include_variants === 'true' || include_variants === '1' ? "s.raw_data->'POSMetaData'->'children' as variants_raw" : 'NULL as variants_raw'} ${include_variants === 'true' || include_variants === '1' ? "s.raw_data->'POSMetaData'->'children' as variants_raw" : 'NULL as variants_raw'}
FROM store_products p FROM store_products p
LEFT JOIN dispensaries d ON d.id = p.dispensary_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT * FROM v_product_snapshots SELECT * FROM v_product_snapshots
WHERE store_product_id = p.id WHERE store_product_id = p.id
@@ -614,22 +611,6 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
? (p.price_rec_special ? parseFloat(p.price_rec_special).toFixed(2) : null) ? (p.price_rec_special ? parseFloat(p.price_rec_special).toFixed(2) : null)
: (p.price_med_special ? parseFloat(p.price_med_special).toFixed(2) : null); : (p.price_med_special ? parseFloat(p.price_med_special).toFixed(2) : null);
// Build product-specific URL from dispensary menu_url and product ID
let productUrl = p.dispensary_menu_url || null;
if (p.dispensary_menu_url && p.dutchie_id) {
// Extract slug from Dutchie URLs and build product link
// Handles: dutchie.com/dispensary/slug, dutchie.com/embedded-menu/slug
const dutchieMatch = p.dispensary_menu_url.match(/dutchie\.com\/(?:dispensary|embedded-menu)\/([^\/\?]+)/);
if (dutchieMatch) {
productUrl = `https://dutchie.com/dispensary/${dutchieMatch[1]}/product/${p.dutchie_id}`;
}
// Handle Jane/iHeartJane URLs
const janeMatch = p.dispensary_menu_url.match(/(?:iheartjane|jane)\.com\/(?:embed|stores)\/([^\/\?]+)/);
if (janeMatch) {
productUrl = `https://www.iheartjane.com/stores/${janeMatch[1]}/products/${p.dutchie_id}`;
}
}
const result: any = { const result: any = {
id: p.id, id: p.id,
dispensary_id: p.dispensary_id, dispensary_id: p.dispensary_id,
@@ -645,7 +626,6 @@ router.get('/products', async (req: PublicApiRequest, res: Response) => {
thc_percentage: p.thc ? parseFloat(p.thc) : null, thc_percentage: p.thc ? parseFloat(p.thc) : null,
cbd_percentage: p.cbd ? parseFloat(p.cbd) : null, cbd_percentage: p.cbd ? parseFloat(p.cbd) : null,
image_url: p.image_url || null, image_url: p.image_url || null,
menu_url: productUrl,
in_stock: p.stock_status === 'in_stock', in_stock: p.stock_status === 'in_stock',
on_special: p.special || false, on_special: p.special || false,
quantity_available: p.total_quantity_available || 0, quantity_available: p.total_quantity_available || 0,
@@ -954,10 +934,8 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
s.special, s.special,
s.options, s.options,
p.updated_at, p.updated_at,
s.crawled_at as snapshot_at, s.crawled_at as snapshot_at
d.menu_url as dispensary_menu_url
FROM v_products p FROM v_products p
LEFT JOIN dispensaries d ON d.id = p.dispensary_id
INNER JOIN LATERAL ( INNER JOIN LATERAL (
SELECT * FROM v_product_snapshots SELECT * FROM v_product_snapshots
WHERE store_product_id = p.id WHERE store_product_id = p.id
@@ -983,21 +961,7 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
${whereClause} ${whereClause}
`, countParams); `, countParams);
const transformedProducts = products.map((p) => { const transformedProducts = products.map((p) => ({
// Build product-specific URL from dispensary menu_url and product ID
let productUrl = p.dispensary_menu_url || null;
if (p.dispensary_menu_url && p.dutchie_id) {
const dutchieMatch = p.dispensary_menu_url.match(/dutchie\.com\/(?:dispensary|embedded-menu)\/([^\/\?]+)/);
if (dutchieMatch) {
productUrl = `https://dutchie.com/dispensary/${dutchieMatch[1]}/product/${p.dutchie_id}`;
}
const janeMatch = p.dispensary_menu_url.match(/(?:iheartjane|jane)\.com\/(?:embed|stores)\/([^\/\?]+)/);
if (janeMatch) {
productUrl = `https://www.iheartjane.com/stores/${janeMatch[1]}/products/${p.dutchie_id}`;
}
}
return {
id: p.id, id: p.id,
dispensary_id: p.dispensary_id, dispensary_id: p.dispensary_id,
dutchie_id: p.dutchie_id, dutchie_id: p.dutchie_id,
@@ -1008,13 +972,11 @@ router.get('/specials', async (req: PublicApiRequest, res: Response) => {
regular_price: p.rec_min_price_cents ? (p.rec_min_price_cents / 100).toFixed(2) : null, regular_price: p.rec_min_price_cents ? (p.rec_min_price_cents / 100).toFixed(2) : null,
sale_price: p.rec_min_special_price_cents ? (p.rec_min_special_price_cents / 100).toFixed(2) : null, sale_price: p.rec_min_special_price_cents ? (p.rec_min_special_price_cents / 100).toFixed(2) : null,
image_url: p.image_url || null, image_url: p.image_url || null,
menu_url: productUrl,
in_stock: p.stock_status === 'in_stock', in_stock: p.stock_status === 'in_stock',
options: p.options || [], options: p.options || [],
updated_at: p.updated_at, updated_at: p.updated_at,
snapshot_at: p.snapshot_at snapshot_at: p.snapshot_at
}; }));
});
res.json({ res.json({
success: true, success: true,
@@ -2040,7 +2002,6 @@ router.get('/menu', async (req: PublicApiRequest, res: Response) => {
success: true, success: true,
scope: scope.type, scope: scope.type,
dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined, dispensary: scope.type === 'wordpress' ? req.apiPermission?.store_name : undefined,
store_id: dispensaryId || null,
menu: { menu: {
total_products: parseInt(summary.total_products || '0', 10), total_products: parseInt(summary.total_products || '0', 10),
in_stock_count: parseInt(summary.in_stock_count || '0', 10), in_stock_count: parseInt(summary.in_stock_count || '0', 10),

View File

@@ -1,295 +0,0 @@
/**
* Sales Analytics API Routes
*
* Market intelligence endpoints for sales velocity, brand market share,
* store performance, and product intelligence.
*
* Routes are prefixed with /api/sales-analytics
*
* Data Sources (materialized views):
* - mv_daily_sales_estimates: Daily sales from inventory deltas
* - mv_brand_market_share: Brand penetration by state
* - mv_sku_velocity: SKU velocity rankings
* - mv_store_performance: Dispensary performance rankings
* - mv_category_weekly_trends: Weekly category trends
* - mv_product_intelligence: Per-product Hoodie-style metrics
*/
import { Router, Request, Response } from 'express';
import { authMiddleware } from '../auth/middleware';
import salesAnalyticsService from '../services/analytics/SalesAnalyticsService';
import { TimeWindow, getDateRangeFromWindow } from '../services/analytics/types';
const router = Router();
// Apply auth middleware to all routes
router.use(authMiddleware);
// ============================================================
// DAILY SALES ESTIMATES
// ============================================================
/**
* GET /daily-sales
* Get daily sales estimates by product/dispensary
*/
router.get('/daily-sales', async (req: Request, res: Response) => {
try {
const stateCode = req.query.state as string | undefined;
const brandName = req.query.brand as string | undefined;
const category = req.query.category as string | undefined;
const dispensaryId = req.query.dispensary_id
? parseInt(req.query.dispensary_id as string)
: undefined;
const limit = req.query.limit
? parseInt(req.query.limit as string)
: 100;
const result = await salesAnalyticsService.getDailySalesEstimates({
stateCode,
brandName,
category,
dispensaryId,
limit,
});
res.json({ success: true, data: result, count: result.length });
} catch (error: any) {
console.error('[SalesAnalytics] Daily sales error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// BRAND MARKET SHARE
// ============================================================
/**
* GET /brand-market-share
* Get brand market share (penetration) by state
*/
router.get('/brand-market-share', async (req: Request, res: Response) => {
try {
const stateCode = req.query.state as string | undefined;
const brandName = req.query.brand as string | undefined;
const minPenetration = req.query.min_penetration
? parseFloat(req.query.min_penetration as string)
: 0;
const limit = req.query.limit
? parseInt(req.query.limit as string)
: 100;
const result = await salesAnalyticsService.getBrandMarketShare({
stateCode,
brandName,
minPenetration,
limit,
});
res.json({ success: true, data: result, count: result.length });
} catch (error: any) {
console.error('[SalesAnalytics] Brand market share error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// SKU VELOCITY
// ============================================================
/**
* GET /sku-velocity
* Get SKU velocity rankings
*/
router.get('/sku-velocity', async (req: Request, res: Response) => {
try {
const stateCode = req.query.state as string | undefined;
const brandName = req.query.brand as string | undefined;
const category = req.query.category as string | undefined;
const dispensaryId = req.query.dispensary_id
? parseInt(req.query.dispensary_id as string)
: undefined;
const velocityTier = req.query.tier as 'hot' | 'steady' | 'slow' | 'stale' | undefined;
const limit = req.query.limit
? parseInt(req.query.limit as string)
: 100;
const result = await salesAnalyticsService.getSkuVelocity({
stateCode,
brandName,
category,
dispensaryId,
velocityTier,
limit,
});
res.json({ success: true, data: result, count: result.length });
} catch (error: any) {
console.error('[SalesAnalytics] SKU velocity error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// STORE PERFORMANCE
// ============================================================
/**
* GET /store-performance
* Get dispensary performance rankings
*/
router.get('/store-performance', async (req: Request, res: Response) => {
try {
const stateCode = req.query.state as string | undefined;
const sortBy = (req.query.sort_by as 'revenue' | 'units' | 'brands' | 'skus') || 'revenue';
const limit = req.query.limit
? parseInt(req.query.limit as string)
: 100;
const result = await salesAnalyticsService.getStorePerformance({
stateCode,
sortBy,
limit,
});
res.json({ success: true, data: result, count: result.length });
} catch (error: any) {
console.error('[SalesAnalytics] Store performance error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// CATEGORY TRENDS
// ============================================================
/**
* GET /category-trends
* Get weekly category performance trends
*/
router.get('/category-trends', async (req: Request, res: Response) => {
try {
const stateCode = req.query.state as string | undefined;
const category = req.query.category as string | undefined;
const weeks = req.query.weeks
? parseInt(req.query.weeks as string)
: 12;
const result = await salesAnalyticsService.getCategoryTrends({
stateCode,
category,
weeks,
});
res.json({ success: true, data: result, count: result.length });
} catch (error: any) {
console.error('[SalesAnalytics] Category trends error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// PRODUCT INTELLIGENCE (Hoodie-style metrics)
// ============================================================
/**
* GET /product-intelligence
* Get per-product metrics including stock_diff_120, days_since_oos, days_until_stock_out
*/
router.get('/product-intelligence', async (req: Request, res: Response) => {
try {
const stateCode = req.query.state as string | undefined;
const brandName = req.query.brand as string | undefined;
const category = req.query.category as string | undefined;
const dispensaryId = req.query.dispensary_id
? parseInt(req.query.dispensary_id as string)
: undefined;
const inStockOnly = req.query.in_stock === 'true';
const lowStock = req.query.low_stock === 'true';
const recentOOS = req.query.recent_oos === 'true';
const limit = req.query.limit
? parseInt(req.query.limit as string)
: 100;
const result = await salesAnalyticsService.getProductIntelligence({
stateCode,
brandName,
category,
dispensaryId,
inStockOnly,
lowStock,
recentOOS,
limit,
});
res.json({ success: true, data: result, count: result.length });
} catch (error: any) {
console.error('[SalesAnalytics] Product intelligence error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// TOP BRANDS
// ============================================================
/**
* GET /top-brands
* Get top selling brands by revenue
*/
router.get('/top-brands', async (req: Request, res: Response) => {
try {
const stateCode = req.query.state as string | undefined;
const window = (req.query.window as TimeWindow) || '30d';
const limit = req.query.limit
? parseInt(req.query.limit as string)
: 50;
const result = await salesAnalyticsService.getTopBrands({
stateCode,
window,
limit,
});
res.json({ success: true, data: result, count: result.length });
} catch (error: any) {
console.error('[SalesAnalytics] Top brands error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
// ============================================================
// VIEW MANAGEMENT
// ============================================================
/**
* POST /refresh
* Manually refresh materialized views (admin only)
*/
router.post('/refresh', async (req: Request, res: Response) => {
try {
console.log('[SalesAnalytics] Manual view refresh requested');
const result = await salesAnalyticsService.refreshViews();
console.log('[SalesAnalytics] View refresh complete:', result);
res.json({ success: true, data: result });
} catch (error: any) {
console.error('[SalesAnalytics] Refresh error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /stats
* Get view statistics (row counts for each materialized view)
*/
router.get('/stats', async (req: Request, res: Response) => {
try {
const stats = await salesAnalyticsService.getViewStats();
res.json({ success: true, data: stats });
} catch (error: any) {
console.error('[SalesAnalytics] Stats error:', error);
res.status(500).json({ success: false, error: error.message });
}
});
export default router;

View File

@@ -26,7 +26,6 @@
*/ */
import { Router, Request, Response } from 'express'; import { Router, Request, Response } from 'express';
import { authMiddleware } from '../auth/middleware';
import { import {
taskService, taskService,
TaskRole, TaskRole,
@@ -598,7 +597,7 @@ router.delete('/schedules/:id', async (req: Request, res: Response) => {
}); });
} }
// Delete the schedule (pending tasks remain in pool for manual management) // Delete the schedule
await pool.query(`DELETE FROM task_schedules WHERE id = $1`, [scheduleId]); await pool.query(`DELETE FROM task_schedules WHERE id = $1`, [scheduleId]);
res.json({ res.json({
@@ -1919,292 +1918,4 @@ router.get('/pools/:id', async (req: Request, res: Response) => {
} }
}); });
// ============================================================
// INVENTORY SNAPSHOTS API
// Part of Real-Time Inventory Tracking feature
// ============================================================
/**
* GET /inventory-snapshots
* Get inventory snapshots with optional filters
*/
router.get('/inventory-snapshots', authMiddleware, async (req: Request, res: Response) => {
try {
const dispensaryId = req.query.dispensary_id ? parseInt(req.query.dispensary_id as string) : undefined;
const productId = req.query.product_id as string | undefined;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
let query = `
SELECT
s.id,
s.dispensary_id,
d.name as dispensary_name,
s.product_id,
s.platform,
s.quantity_available,
s.is_below_threshold,
s.status,
s.price_rec,
s.price_med,
s.brand_name,
s.category,
s.product_name,
s.captured_at
FROM inventory_snapshots s
JOIN dispensaries d ON d.id = s.dispensary_id
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
if (dispensaryId) {
query += ` AND s.dispensary_id = $${paramIndex++}`;
params.push(dispensaryId);
}
if (productId) {
query += ` AND s.product_id = $${paramIndex++}`;
params.push(productId);
}
query += ` ORDER BY s.captured_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
params.push(limit, offset);
const { rows } = await pool.query(query, params);
// Get total count
let countQuery = `SELECT COUNT(*) FROM inventory_snapshots WHERE 1=1`;
const countParams: any[] = [];
let countParamIndex = 1;
if (dispensaryId) {
countQuery += ` AND dispensary_id = $${countParamIndex++}`;
countParams.push(dispensaryId);
}
if (productId) {
countQuery += ` AND product_id = $${countParamIndex++}`;
countParams.push(productId);
}
const { rows: countRows } = await pool.query(countQuery, countParams);
const total = parseInt(countRows[0].count);
res.json({
success: true,
snapshots: rows,
count: total,
limit,
offset,
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /inventory-snapshots/stats
* Get inventory snapshot statistics
*/
router.get('/inventory-snapshots/stats', authMiddleware, async (req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT
COUNT(*) as total_snapshots,
COUNT(DISTINCT dispensary_id) as stores_tracked,
COUNT(DISTINCT product_id) as products_tracked,
MIN(captured_at) as oldest_snapshot,
MAX(captured_at) as newest_snapshot,
COUNT(*) FILTER (WHERE captured_at > NOW() - INTERVAL '24 hours') as snapshots_24h,
COUNT(*) FILTER (WHERE captured_at > NOW() - INTERVAL '1 hour') as snapshots_1h
FROM inventory_snapshots
`);
res.json({
success: true,
stats: rows[0],
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
// ============================================================
// VISIBILITY EVENTS API
// Part of Real-Time Inventory Tracking feature
// ============================================================
/**
* GET /visibility-events
* Get visibility events with optional filters
*/
router.get('/visibility-events', authMiddleware, async (req: Request, res: Response) => {
try {
const dispensaryId = req.query.dispensary_id ? parseInt(req.query.dispensary_id as string) : undefined;
const brand = req.query.brand as string | undefined;
const eventType = req.query.event_type as string | undefined;
const limit = req.query.limit ? parseInt(req.query.limit as string) : 100;
const offset = req.query.offset ? parseInt(req.query.offset as string) : 0;
let query = `
SELECT
e.id,
e.dispensary_id,
d.name as dispensary_name,
e.product_id,
e.product_name,
e.brand_name,
e.event_type,
e.detected_at,
e.previous_quantity,
e.previous_price,
e.new_price,
e.price_change_pct,
e.platform,
e.notified,
e.acknowledged_at
FROM product_visibility_events e
JOIN dispensaries d ON d.id = e.dispensary_id
WHERE 1=1
`;
const params: any[] = [];
let paramIndex = 1;
if (dispensaryId) {
query += ` AND e.dispensary_id = $${paramIndex++}`;
params.push(dispensaryId);
}
if (brand) {
query += ` AND e.brand_name ILIKE $${paramIndex++}`;
params.push(`%${brand}%`);
}
if (eventType) {
query += ` AND e.event_type = $${paramIndex++}`;
params.push(eventType);
}
query += ` ORDER BY e.detected_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex++}`;
params.push(limit, offset);
const { rows } = await pool.query(query, params);
// Get total count
let countQuery = `SELECT COUNT(*) FROM product_visibility_events WHERE 1=1`;
const countParams: any[] = [];
let countParamIndex = 1;
if (dispensaryId) {
countQuery += ` AND dispensary_id = $${countParamIndex++}`;
countParams.push(dispensaryId);
}
if (brand) {
countQuery += ` AND brand_name ILIKE $${countParamIndex++}`;
countParams.push(`%${brand}%`);
}
if (eventType) {
countQuery += ` AND event_type = $${countParamIndex++}`;
countParams.push(eventType);
}
const { rows: countRows } = await pool.query(countQuery, countParams);
const total = parseInt(countRows[0].count);
res.json({
success: true,
events: rows,
count: total,
limit,
offset,
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* GET /visibility-events/stats
* Get visibility event statistics
*/
router.get('/visibility-events/stats', authMiddleware, async (req: Request, res: Response) => {
try {
const { rows } = await pool.query(`
SELECT
COUNT(*) as total_events,
COUNT(*) FILTER (WHERE event_type = 'oos') as oos_events,
COUNT(*) FILTER (WHERE event_type = 'back_in_stock') as back_in_stock_events,
COUNT(*) FILTER (WHERE event_type = 'brand_dropped') as brand_dropped_events,
COUNT(*) FILTER (WHERE event_type = 'brand_added') as brand_added_events,
COUNT(*) FILTER (WHERE event_type = 'price_change') as price_change_events,
COUNT(*) FILTER (WHERE detected_at > NOW() - INTERVAL '24 hours') as events_24h,
COUNT(*) FILTER (WHERE acknowledged_at IS NOT NULL) as acknowledged_events,
COUNT(*) FILTER (WHERE notified = TRUE) as notified_events
FROM product_visibility_events
`);
res.json({
success: true,
stats: rows[0],
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* POST /visibility-events/:id/acknowledge
* Acknowledge a visibility event
*/
router.post('/visibility-events/:id/acknowledge', authMiddleware, async (req: Request, res: Response) => {
try {
const eventId = parseInt(req.params.id);
const acknowledgedBy = (req as any).user?.email || 'unknown';
await pool.query(`
UPDATE product_visibility_events
SET acknowledged_at = NOW(),
acknowledged_by = $2
WHERE id = $1
`, [eventId, acknowledgedBy]);
res.json({
success: true,
message: 'Event acknowledged',
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
/**
* POST /visibility-events/acknowledge-bulk
* Acknowledge multiple visibility events
*/
router.post('/visibility-events/acknowledge-bulk', authMiddleware, async (req: Request, res: Response) => {
try {
const { event_ids } = req.body;
if (!event_ids || !Array.isArray(event_ids)) {
return res.status(400).json({ success: false, error: 'event_ids array required' });
}
const acknowledgedBy = (req as any).user?.email || 'unknown';
const { rowCount } = await pool.query(`
UPDATE product_visibility_events
SET acknowledged_at = NOW(),
acknowledged_by = $2
WHERE id = ANY($1)
`, [event_ids, acknowledgedBy]);
res.json({
success: true,
message: `${rowCount} events acknowledged`,
count: rowCount,
});
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
export default router; export default router;

View File

@@ -1,589 +0,0 @@
/**
* SalesAnalyticsService
*
* Market intelligence and sales velocity analytics using materialized views.
* Provides fast queries for dashboards with pre-computed metrics.
*
* Data Sources:
* - mv_daily_sales_estimates: Daily sales from inventory deltas
* - mv_brand_market_share: Brand penetration by state
* - mv_sku_velocity: SKU velocity rankings
* - mv_store_performance: Dispensary performance rankings
* - mv_category_weekly_trends: Weekly category trends
* - mv_product_intelligence: Per-product Hoodie-style metrics
*/
import { pool } from '../../db/pool';
import { TimeWindow, DateRange, getDateRangeFromWindow } from './types';
// ============================================================
// TYPES
// ============================================================
export interface DailySalesEstimate {
dispensary_id: number;
product_id: string;
brand_name: string | null;
category: string | null;
sale_date: string;
avg_price: number | null;
units_sold: number;
units_restocked: number;
revenue_estimate: number;
snapshot_count: number;
}
export interface BrandMarketShare {
brand_name: string;
state_code: string;
stores_carrying: number;
total_stores: number;
penetration_pct: number;
sku_count: number;
in_stock_skus: number;
avg_price: number | null;
}
export interface SkuVelocity {
product_id: string;
brand_name: string | null;
category: string | null;
dispensary_id: number;
dispensary_name: string;
state_code: string;
total_units_30d: number;
total_revenue_30d: number;
days_with_sales: number;
avg_daily_units: number;
avg_price: number | null;
velocity_tier: 'hot' | 'steady' | 'slow' | 'stale';
}
export interface StorePerformance {
dispensary_id: number;
dispensary_name: string;
city: string | null;
state_code: string;
total_revenue_30d: number;
total_units_30d: number;
total_skus: number;
in_stock_skus: number;
unique_brands: number;
unique_categories: number;
avg_price: number | null;
last_updated: string | null;
}
export interface CategoryWeeklyTrend {
category: string;
state_code: string;
week_start: string;
sku_count: number;
store_count: number;
total_units: number;
total_revenue: number;
avg_price: number | null;
}
export interface ProductIntelligence {
dispensary_id: number;
dispensary_name: string;
state_code: string;
city: string | null;
sku: string;
product_name: string | null;
brand: string | null;
category: string | null;
is_in_stock: boolean;
stock_status: string | null;
stock_quantity: number | null;
price: number | null;
first_seen: string | null;
last_seen: string | null;
stock_diff_120: number;
days_since_oos: number | null;
days_until_stock_out: number | null;
avg_daily_units: number | null;
}
export interface ViewRefreshResult {
view_name: string;
rows_affected: number;
}
// ============================================================
// SERVICE CLASS
// ============================================================
export class SalesAnalyticsService {
/**
* Get daily sales estimates with filters
*/
async getDailySalesEstimates(options: {
stateCode?: string;
brandName?: string;
category?: string;
dispensaryId?: number;
dateRange?: DateRange;
limit?: number;
} = {}): Promise<DailySalesEstimate[]> {
const { stateCode, brandName, category, dispensaryId, dateRange, limit = 100 } = options;
const params: (string | number | Date)[] = [];
let paramIdx = 1;
const conditions: string[] = [];
if (stateCode) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(stateCode);
}
if (brandName) {
conditions.push(`dse.brand_name ILIKE $${paramIdx++}`);
params.push(`%${brandName}%`);
}
if (category) {
conditions.push(`dse.category = $${paramIdx++}`);
params.push(category);
}
if (dispensaryId) {
conditions.push(`dse.dispensary_id = $${paramIdx++}`);
params.push(dispensaryId);
}
if (dateRange) {
conditions.push(`dse.sale_date >= $${paramIdx++}`);
params.push(dateRange.start);
conditions.push(`dse.sale_date <= $${paramIdx++}`);
params.push(dateRange.end);
}
params.push(limit);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(`
SELECT dse.*
FROM mv_daily_sales_estimates dse
JOIN dispensaries d ON d.id = dse.dispensary_id
${whereClause}
ORDER BY dse.sale_date DESC, dse.revenue_estimate DESC
LIMIT $${paramIdx}
`, params);
return result.rows.map((row: any) => ({
dispensary_id: row.dispensary_id,
product_id: row.product_id,
brand_name: row.brand_name,
category: row.category,
sale_date: row.sale_date?.toISOString().split('T')[0] || '',
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
units_sold: parseInt(row.units_sold) || 0,
units_restocked: parseInt(row.units_restocked) || 0,
revenue_estimate: parseFloat(row.revenue_estimate) || 0,
snapshot_count: parseInt(row.snapshot_count) || 0,
}));
}
/**
* Get brand market share by state
*/
async getBrandMarketShare(options: {
stateCode?: string;
brandName?: string;
minPenetration?: number;
limit?: number;
} = {}): Promise<BrandMarketShare[]> {
const { stateCode, brandName, minPenetration = 0, limit = 100 } = options;
const params: (string | number)[] = [];
let paramIdx = 1;
const conditions: string[] = [];
if (stateCode) {
conditions.push(`state_code = $${paramIdx++}`);
params.push(stateCode);
}
if (brandName) {
conditions.push(`brand_name ILIKE $${paramIdx++}`);
params.push(`%${brandName}%`);
}
if (minPenetration > 0) {
conditions.push(`penetration_pct >= $${paramIdx++}`);
params.push(minPenetration);
}
params.push(limit);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(`
SELECT *
FROM mv_brand_market_share
${whereClause}
ORDER BY penetration_pct DESC, stores_carrying DESC
LIMIT $${paramIdx}
`, params);
return result.rows.map((row: any) => ({
brand_name: row.brand_name,
state_code: row.state_code,
stores_carrying: parseInt(row.stores_carrying) || 0,
total_stores: parseInt(row.total_stores) || 0,
penetration_pct: parseFloat(row.penetration_pct) || 0,
sku_count: parseInt(row.sku_count) || 0,
in_stock_skus: parseInt(row.in_stock_skus) || 0,
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
}));
}
/**
* Get SKU velocity rankings
*/
async getSkuVelocity(options: {
stateCode?: string;
brandName?: string;
category?: string;
dispensaryId?: number;
velocityTier?: 'hot' | 'steady' | 'slow' | 'stale';
limit?: number;
} = {}): Promise<SkuVelocity[]> {
const { stateCode, brandName, category, dispensaryId, velocityTier, limit = 100 } = options;
const params: (string | number)[] = [];
let paramIdx = 1;
const conditions: string[] = [];
if (stateCode) {
conditions.push(`state_code = $${paramIdx++}`);
params.push(stateCode);
}
if (brandName) {
conditions.push(`brand_name ILIKE $${paramIdx++}`);
params.push(`%${brandName}%`);
}
if (category) {
conditions.push(`category = $${paramIdx++}`);
params.push(category);
}
if (dispensaryId) {
conditions.push(`dispensary_id = $${paramIdx++}`);
params.push(dispensaryId);
}
if (velocityTier) {
conditions.push(`velocity_tier = $${paramIdx++}`);
params.push(velocityTier);
}
params.push(limit);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(`
SELECT *
FROM mv_sku_velocity
${whereClause}
ORDER BY total_units_30d DESC
LIMIT $${paramIdx}
`, params);
return result.rows.map((row: any) => ({
product_id: row.product_id,
brand_name: row.brand_name,
category: row.category,
dispensary_id: row.dispensary_id,
dispensary_name: row.dispensary_name,
state_code: row.state_code,
total_units_30d: parseInt(row.total_units_30d) || 0,
total_revenue_30d: parseFloat(row.total_revenue_30d) || 0,
days_with_sales: parseInt(row.days_with_sales) || 0,
avg_daily_units: parseFloat(row.avg_daily_units) || 0,
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
velocity_tier: row.velocity_tier,
}));
}
/**
* Get dispensary performance rankings
*/
async getStorePerformance(options: {
stateCode?: string;
sortBy?: 'revenue' | 'units' | 'brands' | 'skus';
limit?: number;
} = {}): Promise<StorePerformance[]> {
const { stateCode, sortBy = 'revenue', limit = 100 } = options;
const params: (string | number)[] = [];
let paramIdx = 1;
const conditions: string[] = [];
if (stateCode) {
conditions.push(`state_code = $${paramIdx++}`);
params.push(stateCode);
}
params.push(limit);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const orderByMap: Record<string, string> = {
revenue: 'total_revenue_30d DESC',
units: 'total_units_30d DESC',
brands: 'unique_brands DESC',
skus: 'total_skus DESC',
};
const orderBy = orderByMap[sortBy] || orderByMap.revenue;
const result = await pool.query(`
SELECT *
FROM mv_store_performance
${whereClause}
ORDER BY ${orderBy}
LIMIT $${paramIdx}
`, params);
return result.rows.map((row: any) => ({
dispensary_id: row.dispensary_id,
dispensary_name: row.dispensary_name,
city: row.city,
state_code: row.state_code,
total_revenue_30d: parseFloat(row.total_revenue_30d) || 0,
total_units_30d: parseInt(row.total_units_30d) || 0,
total_skus: parseInt(row.total_skus) || 0,
in_stock_skus: parseInt(row.in_stock_skus) || 0,
unique_brands: parseInt(row.unique_brands) || 0,
unique_categories: parseInt(row.unique_categories) || 0,
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
last_updated: row.last_updated?.toISOString() || null,
}));
}
/**
* Get category weekly trends
*/
async getCategoryTrends(options: {
stateCode?: string;
category?: string;
weeks?: number;
} = {}): Promise<CategoryWeeklyTrend[]> {
const { stateCode, category, weeks = 12 } = options;
const params: (string | number)[] = [];
let paramIdx = 1;
const conditions: string[] = [];
if (stateCode) {
conditions.push(`state_code = $${paramIdx++}`);
params.push(stateCode);
}
if (category) {
conditions.push(`category = $${paramIdx++}`);
params.push(category);
}
conditions.push(`week_start >= CURRENT_DATE - INTERVAL '${weeks} weeks'`);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(`
SELECT *
FROM mv_category_weekly_trends
${whereClause}
ORDER BY week_start DESC, total_revenue DESC
`, params);
return result.rows.map((row: any) => ({
category: row.category,
state_code: row.state_code,
week_start: row.week_start?.toISOString().split('T')[0] || '',
sku_count: parseInt(row.sku_count) || 0,
store_count: parseInt(row.store_count) || 0,
total_units: parseInt(row.total_units) || 0,
total_revenue: parseFloat(row.total_revenue) || 0,
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
}));
}
/**
* Get product intelligence (Hoodie-style per-product metrics)
*/
async getProductIntelligence(options: {
stateCode?: string;
brandName?: string;
category?: string;
dispensaryId?: number;
inStockOnly?: boolean;
lowStock?: boolean; // days_until_stock_out <= 7
recentOOS?: boolean; // days_since_oos <= 7
limit?: number;
} = {}): Promise<ProductIntelligence[]> {
const { stateCode, brandName, category, dispensaryId, inStockOnly, lowStock, recentOOS, limit = 100 } = options;
const params: (string | number)[] = [];
let paramIdx = 1;
const conditions: string[] = [];
if (stateCode) {
conditions.push(`state_code = $${paramIdx++}`);
params.push(stateCode);
}
if (brandName) {
conditions.push(`brand ILIKE $${paramIdx++}`);
params.push(`%${brandName}%`);
}
if (category) {
conditions.push(`category = $${paramIdx++}`);
params.push(category);
}
if (dispensaryId) {
conditions.push(`dispensary_id = $${paramIdx++}`);
params.push(dispensaryId);
}
if (inStockOnly) {
conditions.push(`is_in_stock = TRUE`);
}
if (lowStock) {
conditions.push(`days_until_stock_out IS NOT NULL AND days_until_stock_out <= 7`);
}
if (recentOOS) {
conditions.push(`days_since_oos IS NOT NULL AND days_since_oos <= 7`);
}
params.push(limit);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(`
SELECT *
FROM mv_product_intelligence
${whereClause}
ORDER BY
CASE WHEN days_until_stock_out IS NOT NULL THEN 0 ELSE 1 END,
days_until_stock_out ASC NULLS LAST,
stock_quantity DESC
LIMIT $${paramIdx}
`, params);
return result.rows.map((row: any) => ({
dispensary_id: row.dispensary_id,
dispensary_name: row.dispensary_name,
state_code: row.state_code,
city: row.city,
sku: row.sku,
product_name: row.product_name,
brand: row.brand,
category: row.category,
is_in_stock: row.is_in_stock,
stock_status: row.stock_status,
stock_quantity: row.stock_quantity ? parseInt(row.stock_quantity) : null,
price: row.price ? parseFloat(row.price) : null,
first_seen: row.first_seen?.toISOString() || null,
last_seen: row.last_seen?.toISOString() || null,
stock_diff_120: parseInt(row.stock_diff_120) || 0,
days_since_oos: row.days_since_oos ? parseInt(row.days_since_oos) : null,
days_until_stock_out: row.days_until_stock_out ? parseInt(row.days_until_stock_out) : null,
avg_daily_units: row.avg_daily_units ? parseFloat(row.avg_daily_units) : null,
}));
}
/**
* Get top selling brands by revenue
*/
async getTopBrands(options: {
stateCode?: string;
window?: TimeWindow;
limit?: number;
} = {}): Promise<Array<{
brand_name: string;
total_revenue: number;
total_units: number;
store_count: number;
sku_count: number;
avg_price: number | null;
}>> {
const { stateCode, window = '30d', limit = 50 } = options;
const params: (string | number)[] = [];
let paramIdx = 1;
const conditions: string[] = [];
const dateRange = getDateRangeFromWindow(window);
conditions.push(`dse.sale_date >= $${paramIdx++}`);
params.push(dateRange.start.toISOString().split('T')[0]);
if (stateCode) {
conditions.push(`d.state = $${paramIdx++}`);
params.push(stateCode);
}
params.push(limit);
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const result = await pool.query(`
SELECT
dse.brand_name,
SUM(dse.revenue_estimate) AS total_revenue,
SUM(dse.units_sold) AS total_units,
COUNT(DISTINCT dse.dispensary_id) AS store_count,
COUNT(DISTINCT dse.product_id) AS sku_count,
AVG(dse.avg_price) AS avg_price
FROM mv_daily_sales_estimates dse
JOIN dispensaries d ON d.id = dse.dispensary_id
${whereClause}
AND dse.brand_name IS NOT NULL
GROUP BY dse.brand_name
ORDER BY total_revenue DESC
LIMIT $${paramIdx}
`, params);
return result.rows.map((row: any) => ({
brand_name: row.brand_name,
total_revenue: parseFloat(row.total_revenue) || 0,
total_units: parseInt(row.total_units) || 0,
store_count: parseInt(row.store_count) || 0,
sku_count: parseInt(row.sku_count) || 0,
avg_price: row.avg_price ? parseFloat(row.avg_price) : null,
}));
}
/**
* Refresh all materialized views
*/
async refreshViews(): Promise<ViewRefreshResult[]> {
try {
const result = await pool.query('SELECT * FROM refresh_sales_analytics_views()');
return result.rows.map((row: any) => ({
view_name: row.view_name,
rows_affected: parseInt(row.rows_affected) || 0,
}));
} catch (error: any) {
// If function doesn't exist yet (migration not run), return empty
if (error.code === '42883') {
console.warn('[SalesAnalytics] refresh_sales_analytics_views() not found - run migration 121');
return [];
}
throw error;
}
}
/**
* Get view statistics (row counts)
*/
async getViewStats(): Promise<Record<string, number>> {
const views = [
'mv_daily_sales_estimates',
'mv_brand_market_share',
'mv_sku_velocity',
'mv_store_performance',
'mv_category_weekly_trends',
'mv_product_intelligence',
];
const stats: Record<string, number> = {};
for (const view of views) {
try {
const result = await pool.query(`SELECT COUNT(*) FROM ${view}`);
stats[view] = parseInt(result.rows[0].count) || 0;
} catch {
stats[view] = -1; // View doesn't exist yet
}
}
return stats;
}
}
export default new SalesAnalyticsService();

View File

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

View File

@@ -331,9 +331,6 @@ export class IdentityPoolService {
/** /**
* Create a new identity with Evomi proxy * Create a new identity with Evomi proxy
* Generates session ID, gets IP, creates fingerprint * Generates session ID, gets IP, creates fingerprint
*
* IMPORTANT: If IP already exists in DB, reuse that identity's fingerprint
* This ensures fingerprints are "sticky" per IP for anti-detection consistency
*/ */
async createIdentity( async createIdentity(
workerId: string, workerId: string,
@@ -368,49 +365,14 @@ export class IdentityPoolService {
timeout: 15000, timeout: 15000,
}); });
ipAddress = response.data?.ip || null; ipAddress = response.data?.ip || null;
console.log(`[IdentityPool] Proxy IP: ${ipAddress} (${proxyResult.geo})`); console.log(`[IdentityPool] New identity IP: ${ipAddress} (${proxyResult.geo})`);
} catch (err: any) { } catch (err: any) {
console.error(`[IdentityPool] Failed to get IP for new identity: ${err.message}`); console.error(`[IdentityPool] Failed to get IP for new identity: ${err.message}`);
// Still create identity - IP will be detected during preflight // Still create identity - IP will be detected during preflight
} }
// STICKY FINGERPRINT: Check if IP already exists in DB // Generate fingerprint
// If so, reuse that identity's fingerprint for consistency
if (ipAddress) {
const existingResult = await this.pool.query(`
SELECT * FROM worker_identities
WHERE ip_address = $1::inet
ORDER BY last_used_at DESC NULLS LAST
LIMIT 1
`, [ipAddress]);
if (existingResult.rows[0]) {
const existing = existingResult.rows[0];
console.log(`[IdentityPool] Found existing identity #${existing.id} for IP ${ipAddress} - reusing fingerprint`);
// Update the existing identity to be active with this worker
// Also update session_id so proxy session matches
const updateResult = await this.pool.query(`
UPDATE worker_identities
SET session_id = $1,
is_active = TRUE,
active_worker_id = $2,
last_used_at = NOW(),
total_sessions = total_sessions + 1
WHERE id = $3
RETURNING *
`, [sessionId, workerId, existing.id]);
if (updateResult.rows[0]) {
console.log(`[IdentityPool] Reactivated identity #${existing.id} for ${stateCode} (sticky fingerprint)`);
return this.rowToIdentity(updateResult.rows[0]);
}
}
}
// IP not found in DB - generate new fingerprint and store
const fingerprint = generateFingerprint(stateCode); const fingerprint = generateFingerprint(stateCode);
console.log(`[IdentityPool] New IP ${ipAddress || 'unknown'} - generated fresh fingerprint`);
// Insert into database // Insert into database
const insertResult = await this.pool.query(` const insertResult = await this.pool.query(`

View File

@@ -1,18 +1,23 @@
/** /**
* Inventory Snapshots Service (Delta-Only) * Inventory Snapshots Service
* *
* Only stores snapshots when something CHANGES (quantity, price, status). * Shared utility for saving lightweight inventory snapshots after each crawl.
* This reduces storage by ~95% while capturing all meaningful events. * Normalizes fields across all platforms (Dutchie, Jane, Treez) into a
* common format for sales velocity tracking and analytics.
* *
* Part of Real-Time Inventory Tracking feature. * Part of Real-Time Inventory Tracking feature.
* *
* Change types: * Field mappings:
* - sale: quantity decreased (qty_delta < 0) * | Field | Dutchie | Jane | Treez |
* - restock: quantity increased (qty_delta > 0) * |-----------|------------------------|--------------------|------------------|
* - price_change: price changed but quantity same * | ID | id | product_id | id |
* - oos: went out of stock (quantity -> 0) * | Quantity | children.quantityAvailable | max_cart_quantity | availableUnits |
* - back_in_stock: came back in stock (0 -> quantity) * | Low stock | isBelowThreshold | false | !isAboveThreshold|
* - new_product: first time seeing this product * | Price rec | recPrices[0] | bucket_price | customMinPrice |
* | Brand | brand.name | brand | brand |
* | Category | category | kind | category |
* | Name | Name | name | name |
* | Status | Status | (presence=active) | status |
*/ */
import { Pool } from 'pg'; import { Pool } from 'pg';
@@ -26,37 +31,11 @@ interface SnapshotRow {
status: string | null; status: string | null;
price_rec: number | null; price_rec: number | null;
price_med: number | null; price_med: number | null;
price_rec_special: number | null;
price_med_special: number | null;
is_on_special: boolean;
brand_name: string | null; brand_name: string | null;
category: string | null; category: string | null;
product_name: string | null; product_name: string | null;
} }
interface PreviousState {
quantity_available: number | null;
price_rec: number | null;
price_med: number | null;
status: string | null;
captured_at: Date;
}
interface DeltaSnapshot extends SnapshotRow {
prev_quantity: number | null;
prev_price_rec: number | null;
prev_price_med: number | null;
prev_status: string | null;
qty_delta: number | null;
price_delta: number | null;
change_type: string;
effective_price_rec: number | null;
effective_price_med: number | null;
revenue_rec: number | null;
revenue_med: number | null;
hours_since_last: number | null;
}
/** /**
* Extract a normalized snapshot row from a raw product based on platform. * Extract a normalized snapshot row from a raw product based on platform.
*/ */
@@ -67,9 +46,6 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
let status: string | null = null; let status: string | null = null;
let priceRec: number | null = null; let priceRec: number | null = null;
let priceMed: number | null = null; let priceMed: number | null = null;
let priceRecSpecial: number | null = null;
let priceMedSpecial: number | null = null;
let isOnSpecial = false;
let brandName: string | null = null; let brandName: string | null = null;
let category: string | null = null; let category: string | null = null;
let productName: string | null = null; let productName: string | null = null;
@@ -99,15 +75,6 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
const medPrices = product.medicalPrices || product.medPrices || []; const medPrices = product.medicalPrices || product.medPrices || [];
priceMed = medPrices.length > 0 ? parseFloat(medPrices[0]) : null; priceMed = medPrices.length > 0 ? parseFloat(medPrices[0]) : null;
// Special/sale prices
if (product.specialPrices && product.specialPrices.length > 0) {
priceRecSpecial = parseFloat(product.specialPrices[0]);
isOnSpecial = true;
} else if (product.discountedPrices && product.discountedPrices.length > 0) {
priceRecSpecial = parseFloat(product.discountedPrices[0]);
isOnSpecial = true;
}
break; break;
} }
@@ -116,24 +83,20 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
productName = product.name; productName = product.name;
brandName = product.brand || null; brandName = product.brand || null;
category = product.kind || null; category = product.kind || null;
status = 'Active'; status = 'Active'; // Jane products present = active
isBelowThreshold = false; isBelowThreshold = false; // Jane doesn't expose this
// Quantity: max_cart_quantity
quantityAvailable = product.max_cart_quantity ?? null; quantityAvailable = product.max_cart_quantity ?? null;
// Price: bucket_price or first available weight-based price
priceRec = priceRec =
product.bucket_price || product.bucket_price ||
product.price_gram || product.price_gram ||
product.price_eighth_ounce || product.price_eighth_ounce ||
product.price_each || product.price_each ||
null; null;
priceMed = null; priceMed = null; // Jane doesn't separate med prices clearly
// Jane sale prices
if (product.discounted_price && priceRec && product.discounted_price < priceRec) {
priceRecSpecial = product.discounted_price;
isOnSpecial = true;
}
break; break;
} }
@@ -144,17 +107,15 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
category = product.category || null; category = product.category || null;
status = product.status || (product.isActive ? 'ACTIVE' : 'INACTIVE'); status = product.status || (product.isActive ? 'ACTIVE' : 'INACTIVE');
// Quantity: availableUnits
quantityAvailable = product.availableUnits ?? null; quantityAvailable = product.availableUnits ?? null;
// Low stock: inverse of isAboveThreshold
isBelowThreshold = product.isAboveThreshold === false; isBelowThreshold = product.isAboveThreshold === false;
// Price: customMinPrice
priceRec = product.customMinPrice ?? null; priceRec = product.customMinPrice ?? null;
priceMed = null; priceMed = null; // Treez doesn't distinguish med pricing
// Treez sale prices
if (product.customOnSaleValue && priceRec && product.customOnSaleValue < priceRec) {
priceRecSpecial = product.customOnSaleValue;
isOnSpecial = true;
}
break; break;
} }
} }
@@ -170,9 +131,6 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
status, status,
price_rec: priceRec, price_rec: priceRec,
price_med: priceMed, price_med: priceMed,
price_rec_special: priceRecSpecial,
price_med_special: priceMedSpecial,
is_on_special: isOnSpecial,
brand_name: brandName, brand_name: brandName,
category, category,
product_name: productName, product_name: productName,
@@ -180,223 +138,61 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
} }
/** /**
* Determine if product state changed and calculate deltas * Save inventory snapshots for all products in a crawl result.
*/ *
function calculateDelta( * Call this after fetching products in any platform handler.
current: SnapshotRow, * Uses bulk insert for efficiency.
previous: PreviousState | null,
now: Date
): DeltaSnapshot | null {
const qtyChanged =
previous?.quantity_available !== current.quantity_available;
const priceRecChanged =
previous?.price_rec !== current.price_rec;
const priceMedChanged =
previous?.price_med !== current.price_med;
const statusChanged =
previous?.status !== current.status;
// No change - skip
if (previous && !qtyChanged && !priceRecChanged && !priceMedChanged && !statusChanged) {
return null;
}
// Calculate qty delta
const prevQty = previous?.quantity_available ?? null;
const currQty = current.quantity_available ?? 0;
const qtyDelta = previous ? currQty - (prevQty ?? 0) : null;
// Calculate price delta
const priceDelta = previous && current.price_rec && previous.price_rec
? current.price_rec - previous.price_rec
: null;
// Determine change type
let changeType = 'new_product';
if (previous) {
if (currQty === 0 && (prevQty ?? 0) > 0) {
changeType = 'oos';
} else if (currQty > 0 && (prevQty ?? 0) === 0) {
changeType = 'back_in_stock';
} else if (qtyDelta !== null && qtyDelta < 0) {
changeType = 'sale';
} else if (qtyDelta !== null && qtyDelta > 0) {
changeType = 'restock';
} else if (priceRecChanged || priceMedChanged) {
changeType = 'price_change';
} else {
changeType = 'status_change';
}
}
// Calculate effective prices (sale price if on special, otherwise regular)
const effectivePriceRec = current.is_on_special && current.price_rec_special
? current.price_rec_special
: current.price_rec;
const effectivePriceMed = current.is_on_special && current.price_med_special
? current.price_med_special
: current.price_med;
// Calculate revenue (only for sales)
let revenueRec: number | null = null;
let revenueMed: number | null = null;
if (changeType === 'sale' && qtyDelta !== null && qtyDelta < 0) {
const unitsSold = Math.abs(qtyDelta);
if (effectivePriceRec) {
revenueRec = unitsSold * effectivePriceRec;
}
if (effectivePriceMed) {
revenueMed = unitsSold * effectivePriceMed;
}
}
// Calculate hours since last snapshot
let hoursSinceLast: number | null = null;
if (previous?.captured_at) {
const msDiff = now.getTime() - previous.captured_at.getTime();
hoursSinceLast = Math.round((msDiff / 3600000) * 100) / 100; // 2 decimal places
}
return {
...current,
prev_quantity: prevQty,
prev_price_rec: previous?.price_rec ?? null,
prev_price_med: previous?.price_med ?? null,
prev_status: previous?.status ?? null,
qty_delta: qtyDelta,
price_delta: priceDelta,
change_type: changeType,
effective_price_rec: effectivePriceRec,
effective_price_med: effectivePriceMed,
revenue_rec: revenueRec,
revenue_med: revenueMed,
hours_since_last: hoursSinceLast,
};
}
/**
* Get the previous snapshot state for a dispensary.
* Returns a map of product_id -> previous state.
*/
export async function getPreviousSnapshots(
pool: Pool,
dispensaryId: number
): Promise<Map<string, PreviousState>> {
const result = await pool.query(
`
SELECT DISTINCT ON (product_id)
product_id,
quantity_available,
price_rec,
price_med,
status,
captured_at
FROM inventory_snapshots
WHERE dispensary_id = $1
ORDER BY product_id, captured_at DESC
`,
[dispensaryId]
);
const map = new Map<string, PreviousState>();
for (const row of result.rows) {
map.set(row.product_id, {
quantity_available: row.quantity_available,
price_rec: row.price_rec ? parseFloat(row.price_rec) : null,
price_med: row.price_med ? parseFloat(row.price_med) : null,
status: row.status,
captured_at: row.captured_at,
});
}
return map;
}
/**
* Save delta-only inventory snapshots.
* Only stores rows where something changed (qty, price, or status).
* *
* @param pool - Database connection pool * @param pool - Database connection pool
* @param dispensaryId - The dispensary ID * @param dispensaryId - The dispensary ID
* @param products - Array of raw products from the platform * @param products - Array of raw products from the platform
* @param platform - The platform type * @param platform - The platform type
* @returns Object with counts: { total, changed, sales, restocks } * @returns Number of snapshots saved
*/ */
export async function saveInventorySnapshots( export async function saveInventorySnapshots(
pool: Pool, pool: Pool,
dispensaryId: number, dispensaryId: number,
products: any[], products: any[],
platform: Platform platform: Platform
): Promise<{ total: number; changed: number; sales: number; restocks: number; revenue: number }> { ): Promise<number> {
if (!products || products.length === 0) { if (!products || products.length === 0) {
return { total: 0, changed: 0, sales: 0, restocks: 0, revenue: 0 }; return 0;
} }
const now = new Date(); const snapshots: SnapshotRow[] = [];
// Get previous state for comparison
const previousStates = await getPreviousSnapshots(pool, dispensaryId);
// Normalize products and calculate deltas
const deltas: DeltaSnapshot[] = [];
let salesCount = 0;
let restockCount = 0;
let totalRevenue = 0;
for (const product of products) { for (const product of products) {
const normalized = normalizeProduct(product, platform); const row = normalizeProduct(product, platform);
if (!normalized) continue; if (row) {
snapshots.push(row);
const previous = previousStates.get(normalized.product_id) || null;
const delta = calculateDelta(normalized, previous, now);
if (delta) {
deltas.push(delta);
if (delta.change_type === 'sale') {
salesCount++;
totalRevenue += (delta.revenue_rec || 0) + (delta.revenue_med || 0);
} else if (delta.change_type === 'restock') {
restockCount++;
}
} }
} }
if (deltas.length === 0) { if (snapshots.length === 0) {
return { total: products.length, changed: 0, sales: 0, restocks: 0, revenue: 0 }; return 0;
} }
// Bulk insert deltas // Bulk insert using VALUES list
// Build parameterized query
const values: any[] = []; const values: any[] = [];
const placeholders: string[] = []; const placeholders: string[] = [];
let paramIndex = 1; let paramIndex = 1;
for (const d of deltas) { for (const s of snapshots) {
placeholders.push( placeholders.push(
`($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})` `($${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++}, $${paramIndex++})`
); );
values.push( values.push(
dispensaryId, dispensaryId,
d.product_id, s.product_id,
platform, platform,
d.quantity_available, s.quantity_available,
d.is_below_threshold, s.is_below_threshold,
d.status, s.status,
d.price_rec, s.price_rec,
d.price_med, s.price_med,
d.brand_name, s.brand_name,
d.category, s.category,
d.product_name, s.product_name
d.prev_quantity,
d.prev_price_rec,
d.prev_price_med,
d.prev_status,
d.qty_delta,
d.price_delta,
d.change_type,
d.effective_price_rec,
d.effective_price_med,
d.revenue_rec,
d.revenue_med,
d.hours_since_last
); );
} }
@@ -412,71 +208,45 @@ export async function saveInventorySnapshots(
price_med, price_med,
brand_name, brand_name,
category, category,
product_name, product_name
prev_quantity,
prev_price_rec,
prev_price_med,
prev_status,
qty_delta,
price_delta,
change_type,
effective_price_rec,
effective_price_med,
revenue_rec,
revenue_med,
hours_since_last
) VALUES ${placeholders.join(', ')} ) VALUES ${placeholders.join(', ')}
`; `;
await pool.query(query, values); await pool.query(query, values);
return { return snapshots.length;
total: products.length,
changed: deltas.length,
sales: salesCount,
restocks: restockCount,
revenue: Math.round(totalRevenue * 100) / 100,
};
} }
/** /**
* Get snapshot statistics for a dispensary * Get the previous snapshot for a dispensary (for delta calculation).
* Returns a map of product_id -> snapshot data.
*/ */
export async function getSnapshotStats( export async function getPreviousSnapshots(
pool: Pool, pool: Pool,
dispensaryId: number, dispensaryId: number
hours: number = 24 ): Promise<Map<string, SnapshotRow>> {
): Promise<{
totalSnapshots: number;
sales: number;
restocks: number;
priceChanges: number;
oosEvents: number;
revenue: number;
}> {
const result = await pool.query( const result = await pool.query(
` `
SELECT SELECT DISTINCT ON (product_id)
COUNT(*) as total, product_id,
COUNT(*) FILTER (WHERE change_type = 'sale') as sales, quantity_available,
COUNT(*) FILTER (WHERE change_type = 'restock') as restocks, is_below_threshold,
COUNT(*) FILTER (WHERE change_type = 'price_change') as price_changes, status,
COUNT(*) FILTER (WHERE change_type = 'oos') as oos_events, price_rec,
COALESCE(SUM(revenue_rec), 0) + COALESCE(SUM(revenue_med), 0) as revenue price_med,
brand_name,
category,
product_name
FROM inventory_snapshots FROM inventory_snapshots
WHERE dispensary_id = $1 WHERE dispensary_id = $1
AND captured_at >= NOW() - INTERVAL '1 hour' * $2 ORDER BY product_id, captured_at DESC
`, `,
[dispensaryId, hours] [dispensaryId]
); );
const row = result.rows[0]; const map = new Map<string, SnapshotRow>();
return { for (const row of result.rows) {
totalSnapshots: parseInt(row.total), map.set(row.product_id, row);
sales: parseInt(row.sales), }
restocks: parseInt(row.restocks), return map;
priceChanges: parseInt(row.price_changes),
oosEvents: parseInt(row.oos_events),
revenue: parseFloat(row.revenue) || 0,
};
} }

View File

@@ -262,10 +262,9 @@ class TaskScheduler {
source: 'high_frequency_schedule', source: 'high_frequency_schedule',
}); });
// Add jitter: interval + random(-3, +3) minutes // Add jitter: interval + random(0, 20% of interval)
const JITTER_MINUTES = 3; const jitterMinutes = Math.floor(Math.random() * (store.crawl_interval_minutes * 0.2));
const jitterMinutes = Math.floor((Math.random() * JITTER_MINUTES * 2) - JITTER_MINUTES); const nextIntervalMinutes = store.crawl_interval_minutes + jitterMinutes;
const nextIntervalMinutes = Math.max(1, store.crawl_interval_minutes + jitterMinutes);
// Update next_crawl_at and last_crawl_started_at // Update next_crawl_at and last_crawl_started_at
await pool.query(` await pool.query(`

View File

@@ -1,245 +0,0 @@
/**
* Wasabi S3 Storage Service
*
* Stores raw crawl payloads to Wasabi S3-compatible storage for long-term archive.
* Payloads can be reprocessed later if analytics logic changes.
*
* Environment variables:
* - WASABI_ACCESS_KEY: Wasabi access key
* - WASABI_SECRET_KEY: Wasabi secret key
* - WASABI_BUCKET: Bucket name (default: cannaiq-payloads)
* - WASABI_REGION: Region (default: us-east-1)
* - WASABI_ENDPOINT: Endpoint URL (default: s3.wasabisys.com)
*/
import { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } from '@aws-sdk/client-s3';
import { Readable } from 'stream';
import * as zlib from 'zlib';
interface WasabiConfig {
accessKey: string;
secretKey: string;
bucket: string;
region: string;
endpoint: string;
}
function getConfig(): WasabiConfig {
return {
accessKey: process.env.WASABI_ACCESS_KEY || '',
secretKey: process.env.WASABI_SECRET_KEY || '',
bucket: process.env.WASABI_BUCKET || 'cannaiq',
region: process.env.WASABI_REGION || 'us-west-2',
endpoint: process.env.WASABI_ENDPOINT || 'https://s3.us-west-2.wasabisys.com',
};
}
let s3Client: S3Client | null = null;
function getClient(): S3Client {
if (s3Client) return s3Client;
const config = getConfig();
if (!config.accessKey || !config.secretKey) {
throw new Error('Wasabi credentials not configured (WASABI_ACCESS_KEY, WASABI_SECRET_KEY)');
}
s3Client = new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey,
},
forcePathStyle: true, // Required for Wasabi
});
return s3Client;
}
/**
* Generate storage path for a payload
* Format: payloads/{state}/{YYYY-MM-DD}/{dispensary_id}/{timestamp}.json.gz
*/
export function getPayloadPath(
dispensaryId: number,
stateCode: string,
platform: string,
timestamp: Date = new Date()
): string {
const date = timestamp.toISOString().split('T')[0]; // YYYY-MM-DD
const ts = timestamp.toISOString().replace(/[:.]/g, '-'); // Safe filename
return `payloads/${stateCode.toUpperCase()}/${date}/${dispensaryId}/${platform}_${ts}.json.gz`;
}
/**
* Store a raw payload to Wasabi
* Compresses with gzip before upload to save space (~70% compression on JSON)
*/
export async function storePayload(
dispensaryId: number,
stateCode: string,
platform: string,
payload: any,
metadata?: Record<string, string>
): Promise<{ path: string; sizeBytes: number; compressedBytes: number }> {
const config = getConfig();
const client = getClient();
const jsonString = JSON.stringify(payload);
const originalSize = Buffer.byteLength(jsonString, 'utf8');
// Compress with gzip
const compressed = await new Promise<Buffer>((resolve, reject) => {
zlib.gzip(jsonString, { level: 9 }, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
const path = getPayloadPath(dispensaryId, stateCode, platform);
await client.send(new PutObjectCommand({
Bucket: config.bucket,
Key: path,
Body: compressed,
ContentType: 'application/json',
ContentEncoding: 'gzip',
Metadata: {
dispensaryId: String(dispensaryId),
stateCode: stateCode,
platform: platform,
originalSize: String(originalSize),
productCount: String(Array.isArray(payload) ? payload.length : 0),
...metadata,
},
}));
return {
path,
sizeBytes: originalSize,
compressedBytes: compressed.length,
};
}
/**
* Retrieve a payload from Wasabi
*/
export async function getPayload(path: string): Promise<any> {
const config = getConfig();
const client = getClient();
const response = await client.send(new GetObjectCommand({
Bucket: config.bucket,
Key: path,
}));
if (!response.Body) {
throw new Error(`Empty response for ${path}`);
}
// Read stream to buffer
const stream = response.Body as Readable;
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
const compressed = Buffer.concat(chunks);
// Decompress
const decompressed = await new Promise<Buffer>((resolve, reject) => {
zlib.gunzip(compressed, (err, result) => {
if (err) reject(err);
else resolve(result);
});
});
return JSON.parse(decompressed.toString('utf8'));
}
/**
* List payloads for a dispensary on a specific date
*/
export async function listPayloads(
dispensaryId: number,
stateCode: string,
date: string // YYYY-MM-DD
): Promise<string[]> {
const config = getConfig();
const client = getClient();
const prefix = `payloads/${stateCode.toUpperCase()}/${date}/${dispensaryId}/`;
const response = await client.send(new ListObjectsV2Command({
Bucket: config.bucket,
Prefix: prefix,
}));
return (response.Contents || []).map(obj => obj.Key!).filter(Boolean);
}
/**
* Check if Wasabi is configured and accessible
*/
export async function checkConnection(): Promise<{ connected: boolean; error?: string }> {
try {
const config = getConfig();
if (!config.accessKey || !config.secretKey) {
return { connected: false, error: 'Credentials not configured' };
}
const client = getClient();
// Try to list bucket (will fail if credentials or bucket invalid)
await client.send(new ListObjectsV2Command({
Bucket: config.bucket,
MaxKeys: 1,
}));
return { connected: true };
} catch (error: any) {
return { connected: false, error: error.message };
}
}
/**
* Get storage statistics
*/
export async function getStorageStats(
stateCode?: string,
date?: string
): Promise<{ objectCount: number; totalSizeBytes: number }> {
const config = getConfig();
const client = getClient();
let prefix = 'payloads/';
if (stateCode) {
prefix += `${stateCode.toUpperCase()}/`;
if (date) {
prefix += `${date}/`;
}
}
let objectCount = 0;
let totalSizeBytes = 0;
let continuationToken: string | undefined;
do {
const response = await client.send(new ListObjectsV2Command({
Bucket: config.bucket,
Prefix: prefix,
ContinuationToken: continuationToken,
}));
for (const obj of response.Contents || []) {
objectCount++;
totalSizeBytes += obj.Size || 0;
}
continuationToken = response.NextContinuationToken;
} while (continuationToken);
return { objectCount, totalSizeBytes };
}

View File

@@ -12,8 +12,6 @@
import { pool } from '../db/pool'; import { pool } from '../db/pool';
import { buildEvomiProxyUrl, getEvomiConfig } from './crawl-rotator'; import { buildEvomiProxyUrl, getEvomiConfig } from './crawl-rotator';
import { generateFingerprint, IdentityFingerprint } from './identity-pool';
import { runPuppeteerPreflightWithRetry } from './puppeteer-preflight';
export interface ClaimedTask { export interface ClaimedTask {
task_id: number; task_id: number;
@@ -46,15 +44,9 @@ export interface SessionWithTasks {
session: WorkerSession; session: WorkerSession;
tasks: ClaimedTask[]; tasks: ClaimedTask[];
proxyUrl: string; proxyUrl: string;
fingerprint: IdentityFingerprint;
qualified: boolean; // True if preflight passed
}
// Random 3-5 tasks per session for natural traffic patterns
function getRandomTaskCount(): number {
return 3 + Math.floor(Math.random() * 3); // 3, 4, or 5
} }
const MAX_TASKS_PER_SESSION = 6;
const MAX_IP_ATTEMPTS = 10; // How many IPs to try before giving up const MAX_IP_ATTEMPTS = 10; // How many IPs to try before giving up
const COOLDOWN_HOURS = 8; const COOLDOWN_HOURS = 8;
@@ -63,14 +55,11 @@ const COOLDOWN_HOURS = 8;
* This is the main entry point for the new worker flow. * This is the main entry point for the new worker flow.
* *
* Flow: * Flow:
* 1. Claim ONE task first (determines geo) * 1. Claim up to 6 tasks for same geo
* 2. Get IP matching task's city/state * 2. Get Evomi proxy for that geo
* 3. Go back to pool, claim more tasks (random 2-4 more) for SAME geo * 3. Try IPs until we find one that's available
* 4. Total 3-5 tasks per session * 4. Lock IP to this worker
* 5. Return session + tasks + proxy URL * 5. Return session + tasks + proxy URL
*
* IMPORTANT: Worker does NOT get IP until first task is claimed.
* IP must match task's city/state for geoip consistency.
*/ */
export async function claimSessionWithTasks( export async function claimSessionWithTasks(
workerId: string, workerId: string,
@@ -81,21 +70,21 @@ export async function claimSessionWithTasks(
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
// Step 1: Claim ONE task first to determine geo // Step 1: Claim up to 6 tasks for same geo
const { rows: firstTaskRows } = await client.query<ClaimedTask>( const { rows: tasks } = await client.query<ClaimedTask>(
`SELECT * FROM claim_tasks_batch($1, $2, $3)`, `SELECT * FROM claim_tasks_batch($1, $2, $3)`,
[workerId, 1, role || null] [workerId, MAX_TASKS_PER_SESSION, role || null]
); );
if (firstTaskRows.length === 0) { if (tasks.length === 0) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
console.log(`[WorkerSession] No pending tasks available for ${workerId}`); console.log(`[WorkerSession] No pending tasks available for ${workerId}`);
return null; return null;
} }
const firstTask = firstTaskRows[0]; // Get geo from first claimed task (all same geo)
const { state_code, city } = firstTask; const { state_code, city } = tasks[0];
console.log(`[WorkerSession] ${workerId} claimed first task #${firstTask.task_id} for ${city || 'any'}, ${state_code}`); console.log(`[WorkerSession] ${workerId} claimed ${tasks.length} tasks for ${city || 'any'}, ${state_code}`);
// Step 2: Get Evomi proxy for this geo // Step 2: Get Evomi proxy for this geo
const evomiConfig = getEvomiConfig(); const evomiConfig = getEvomiConfig();
@@ -107,7 +96,6 @@ export async function claimSessionWithTasks(
// Step 3: Try to get an available IP // Step 3: Try to get an available IP
let session: WorkerSession | null = null; let session: WorkerSession | null = null;
let proxyUrl: string | null = null; let proxyUrl: string | null = null;
let lockedFingerprint: IdentityFingerprint | null = null;
for (let attempt = 0; attempt < MAX_IP_ATTEMPTS; attempt++) { for (let attempt = 0; attempt < MAX_IP_ATTEMPTS; attempt++) {
// Build proxy URL with unique session ID for each attempt // Build proxy URL with unique session ID for each attempt
@@ -119,7 +107,8 @@ export async function claimSessionWithTasks(
continue; continue;
} }
// Get actual IP from proxy // TODO: Actually make a request through the proxy to get the real IP
// For now, we'll use a placeholder - in production, run a quick IP check
const testIp = await getProxyIp(proxyResult.url); const testIp = await getProxyIp(proxyResult.url);
if (!testIp) { if (!testIp) {
@@ -127,36 +116,15 @@ export async function claimSessionWithTasks(
continue; continue;
} }
// Check if this IP already has a fingerprint in DB (1 IP = 1 fingerprint rule) // Step 4: Try to lock this IP
const existingSession = await client.query(
`SELECT fingerprint_data FROM worker_sessions
WHERE ip_address = $1 AND fingerprint_data IS NOT NULL
ORDER BY created_at DESC LIMIT 1`,
[testIp]
);
let fingerprintData: IdentityFingerprint;
if (existingSession.rows[0]?.fingerprint_data) {
// STICKY FINGERPRINT: Reuse existing fingerprint for this IP
fingerprintData = existingSession.rows[0].fingerprint_data;
console.log(`[WorkerSession] Reusing sticky fingerprint for IP ${testIp}`);
} else {
// NEW IP: Generate fresh fingerprint and record it
fingerprintData = generateFingerprint(state_code);
console.log(`[WorkerSession] NEW IP ${testIp} - generated fingerprint, recording to pool`);
}
// Lock session with fingerprint (DB generates hash if not provided)
const { rows } = await client.query<WorkerSession>( const { rows } = await client.query<WorkerSession>(
`SELECT * FROM lock_worker_session($1, $2, $3, $4, $5, $6)`, `SELECT * FROM lock_worker_session($1, $2, $3, $4)`,
[workerId, testIp, state_code, city, null, JSON.stringify(fingerprintData)] [workerId, testIp, state_code, city]
); );
if (rows[0]?.id) { if (rows[0]?.id) {
session = rows[0]; session = rows[0];
proxyUrl = proxyResult.url; proxyUrl = proxyResult.url;
lockedFingerprint = fingerprintData;
console.log(`[WorkerSession] ${workerId} locked IP ${testIp} for ${city || 'any'}, ${state_code}`); console.log(`[WorkerSession] ${workerId} locked IP ${testIp} for ${city || 'any'}, ${state_code}`);
break; break;
} }
@@ -172,87 +140,18 @@ export async function claimSessionWithTasks(
return null; return null;
} }
// ========================================================================= // Update session with task count
// STEP 4: PREFLIGHT/QUALIFY - Worker must qualify before proceeding
// =========================================================================
// Rules:
// - 1 IP = 1 fingerprint (enforced above via sticky lookup)
// - Verify antidetect is working (timezone/geo matches IP)
// - If preflight fails, release task and session, return null
// =========================================================================
console.log(`[WorkerSession] ${workerId} running preflight to qualify...`);
const preflightResult = await runPuppeteerPreflightWithRetry(
undefined, // No crawl rotator needed, we have custom proxy
1, // 1 retry
proxyUrl, // Use the proxy we just locked
state_code // Target state for geo verification
);
if (!preflightResult.passed) {
// PREFLIGHT FAILED - Worker not qualified
console.error(`[WorkerSession] ${workerId} PREFLIGHT FAILED: ${preflightResult.error}`);
// Release first task back to pool
await client.query(`SELECT release_claimed_tasks($1)`, [workerId]);
// Mark session as unhealthy and retire it
await client.query(`
UPDATE worker_sessions
SET status = 'cooldown',
cooldown_until = NOW() + INTERVAL '1 hour',
worker_id = NULL
WHERE id = $1
`, [session.id]);
await client.query('ROLLBACK');
return null;
}
console.log(`[WorkerSession] ${workerId} QUALIFIED - preflight passed (${preflightResult.responseTimeMs}ms)`);
// Verify IP matches what we expected
if (preflightResult.proxyIp && preflightResult.proxyIp !== session.ip_address) {
console.warn(`[WorkerSession] IP mismatch: expected ${session.ip_address}, got ${preflightResult.proxyIp}`);
}
// Set GOLD BADGE - worker is now qualified
await client.query(
`SELECT set_worker_qualified($1, $2)`,
[workerId, session.id]
);
console.log(`[WorkerSession] ${workerId} awarded GOLD BADGE`);
// =========================================================================
// STEP 5: Now qualified - claim more tasks for SAME geo (random 2-4 more)
// =========================================================================
// Total will be 3-5 tasks (1 first + 2-4 additional)
const additionalTaskCount = 2 + Math.floor(Math.random() * 3); // 2, 3, or 4
const { rows: additionalTasks } = await client.query<ClaimedTask>(
`SELECT * FROM claim_tasks_batch_for_geo($1, $2, $3, $4, $5)`,
[workerId, additionalTaskCount, state_code, city, role || null]
);
// Combine first task + additional tasks
const allTasks = [firstTask, ...additionalTasks];
const totalTasks = allTasks.length;
console.log(`[WorkerSession] ${workerId} claimed ${additionalTasks.length} more tasks, total: ${totalTasks} for ${city || 'any'}, ${state_code}`);
// Update session with total task count
await client.query( await client.query(
`SELECT session_task_claimed($1, $2)`, `SELECT session_task_claimed($1, $2)`,
[workerId, totalTasks] [workerId, tasks.length]
); );
await client.query('COMMIT'); await client.query('COMMIT');
return { return {
session, session,
tasks: allTasks, tasks,
proxyUrl, proxyUrl,
fingerprint: lockedFingerprint!,
qualified: true, // Preflight passed
}; };
} catch (err) { } catch (err) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
@@ -386,18 +285,13 @@ export async function isSessionComplete(workerId: string): Promise<boolean> {
/** /**
* Retire a worker's session (start 8hr cooldown) * Retire a worker's session (start 8hr cooldown)
* Clears gold badge - worker must re-qualify with new session
*/ */
export async function retireSession(workerId: string): Promise<boolean> { export async function retireSession(workerId: string): Promise<boolean> {
const { rows } = await pool.query( const { rows } = await pool.query(
`SELECT retire_worker_session($1) as success`, `SELECT retire_worker_session($1) as success`,
[workerId] [workerId]
); );
console.log(`[WorkerSession] ${workerId} session retired, IP in ${COOLDOWN_HOURS}hr cooldown`);
// Clear GOLD BADGE - worker no longer qualified
await pool.query(`SELECT clear_worker_badge($1)`, [workerId]);
console.log(`[WorkerSession] ${workerId} session retired, badge cleared, IP in ${COOLDOWN_HOURS}hr cooldown`);
return rows[0]?.success || false; return rows[0]?.success || false;
} }

View File

@@ -26,7 +26,6 @@ import { saveDailyBaseline } from '../../utils/payload-storage';
import { taskService } from '../task-service'; import { taskService } from '../task-service';
import { saveInventorySnapshots } from '../../services/inventory-snapshots'; import { saveInventorySnapshots } from '../../services/inventory-snapshots';
import { detectVisibilityEvents } from '../../services/visibility-events'; import { detectVisibilityEvents } from '../../services/visibility-events';
import { storePayload as storeWasabiPayload, checkConnection as checkWasabiConnection } from '../../services/wasabi-storage';
// GraphQL hash for FilteredProducts query - MUST match CLAUDE.md // GraphQL hash for FilteredProducts query - MUST match CLAUDE.md
const FILTERED_PRODUCTS_HASH = 'ee29c060826dc41c527e470e9ae502c9b2c169720faa0a9f5d25e1b9a530a4a0'; const FILTERED_PRODUCTS_HASH = 'ee29c060826dc41c527e470e9ae502c9b2c169720faa0a9f5d25e1b9a530a4a0';
@@ -368,8 +367,9 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
await ctx.heartbeat(); await ctx.heartbeat();
// ============================================================ // ============================================================
// STEP 5: Archive raw payload to Wasabi S3 (long-term storage) // STEP 5: Save daily baseline (full payload) if in window
// Every crawl is archived for potential reprocessing // Daily baselines are saved once per day per store (12:01 AM - 3:00 AM)
// Outside this window, only inventory snapshots are saved (Step 5.5)
// ============================================================ // ============================================================
updateStep('saving', `Saving ${result.products.length} products`); updateStep('saving', `Saving ${result.products.length} products`);
const rawPayload = { const rawPayload = {
@@ -381,37 +381,6 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
products: result.products, products: result.products,
}; };
// Archive to Wasabi S3 (if configured)
let wasabiPath: string | null = null;
try {
const wasabiResult = await storeWasabiPayload(
dispensaryId,
dispensary.state || 'XX',
'dutchie',
rawPayload,
{
taskId: String(task.id),
cName,
productCount: String(result.products.length),
}
);
wasabiPath = wasabiResult.path;
const compressionRatio = Math.round((1 - wasabiResult.compressedBytes / wasabiResult.sizeBytes) * 100);
console.log(`[ProductDiscoveryHTTP] Archived to Wasabi: ${wasabiPath} (${(wasabiResult.compressedBytes / 1024).toFixed(1)}KB, ${compressionRatio}% compression)`);
} catch (wasabiErr: any) {
// Wasabi archival is optional - don't fail the task if it fails
if (wasabiErr.message?.includes('not configured')) {
console.log(`[ProductDiscoveryHTTP] Wasabi not configured, skipping archive`);
} else {
console.warn(`[ProductDiscoveryHTTP] Wasabi archive failed: ${wasabiErr.message}`);
}
}
// ============================================================
// STEP 5b: Save daily baseline to PostgreSQL (if in window)
// Daily baselines are saved once per day per store (12:01 AM - 3:00 AM)
// Outside this window, only inventory snapshots are saved (Step 5.5)
// ============================================================
// saveDailyBaseline returns null if outside window or baseline already exists today // saveDailyBaseline returns null if outside window or baseline already exists today
const payloadResult = await saveDailyBaseline( const payloadResult = await saveDailyBaseline(
pool, pool,
@@ -426,7 +395,7 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
if (payloadResult) { if (payloadResult) {
console.log(`[ProductDiscoveryHTTP] Saved daily baseline #${payloadResult.id} (${(payloadResult.sizeBytes / 1024).toFixed(1)}KB)`); console.log(`[ProductDiscoveryHTTP] Saved daily baseline #${payloadResult.id} (${(payloadResult.sizeBytes / 1024).toFixed(1)}KB)`);
} else { } else {
console.log(`[ProductDiscoveryHTTP] Skipped PostgreSQL baseline (outside window or already exists)`); console.log(`[ProductDiscoveryHTTP] Skipped full payload save (outside baseline window or already exists)`);
} }
// ============================================================ // ============================================================
@@ -490,7 +459,6 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
productCount: result.products.length, productCount: result.products.length,
sizeBytes: payloadResult?.sizeBytes || 0, sizeBytes: payloadResult?.sizeBytes || 0,
baselineSaved: !!payloadResult, baselineSaved: !!payloadResult,
wasabiPath,
snapshotCount, snapshotCount,
eventCount, eventCount,
}; };

View File

@@ -19,7 +19,6 @@ import { saveDailyBaseline } from '../../utils/payload-storage';
import { taskService } from '../task-service'; import { taskService } from '../task-service';
import { saveInventorySnapshots } from '../../services/inventory-snapshots'; import { saveInventorySnapshots } from '../../services/inventory-snapshots';
import { detectVisibilityEvents } from '../../services/visibility-events'; import { detectVisibilityEvents } from '../../services/visibility-events';
import { storePayload as storeWasabiPayload } from '../../services/wasabi-storage';
export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<TaskResult> { export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<TaskResult> {
const { pool, task, crawlRotator } = ctx; const { pool, task, crawlRotator } = ctx;
@@ -37,7 +36,7 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
try { try {
// Load dispensary // Load dispensary
const dispResult = await pool.query( const dispResult = await pool.query(
`SELECT id, name, menu_url, platform_dispensary_id, menu_type, state `SELECT id, name, menu_url, platform_dispensary_id, menu_type
FROM dispensaries WHERE id = $1`, FROM dispensaries WHERE id = $1`,
[dispensaryId] [dispensaryId]
); );
@@ -100,32 +99,7 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
storeId: dispensary.platform_dispensary_id, storeId: dispensary.platform_dispensary_id,
}; };
// Archive to Wasabi S3 (if configured) // Save daily baseline to filesystem (only in 12:01-3:00 AM window, once per day)
let wasabiPath: string | null = null;
try {
const wasabiResult = await storeWasabiPayload(
dispensaryId,
dispensary.state || 'XX',
'jane',
rawPayload,
{
taskId: String(task.id),
storeId: dispensary.platform_dispensary_id,
productCount: String(result.products.length),
}
);
wasabiPath = wasabiResult.path;
const compressionRatio = Math.round((1 - wasabiResult.compressedBytes / wasabiResult.sizeBytes) * 100);
console.log(`[JaneProductDiscovery] Archived to Wasabi: ${wasabiPath} (${(wasabiResult.compressedBytes / 1024).toFixed(1)}KB, ${compressionRatio}% compression)`);
} catch (wasabiErr: any) {
if (wasabiErr.message?.includes('not configured')) {
console.log(`[JaneProductDiscovery] Wasabi not configured, skipping archive`);
} else {
console.warn(`[JaneProductDiscovery] Wasabi archive failed: ${wasabiErr.message}`);
}
}
// Save daily baseline to PostgreSQL (only in 12:01-3:00 AM window, once per day)
const payloadResult = await saveDailyBaseline( const payloadResult = await saveDailyBaseline(
pool, pool,
dispensaryId, dispensaryId,
@@ -139,7 +113,7 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
if (payloadResult) { if (payloadResult) {
console.log(`[JaneProductDiscovery] Saved daily baseline ${payloadResult.id} (${Math.round(payloadResult.sizeBytes / 1024)}KB)`); console.log(`[JaneProductDiscovery] Saved daily baseline ${payloadResult.id} (${Math.round(payloadResult.sizeBytes / 1024)}KB)`);
} else { } else {
console.log(`[JaneProductDiscovery] Skipped PostgreSQL baseline (outside window or already exists)`); console.log(`[JaneProductDiscovery] Skipped full payload save (outside baseline window or already exists)`);
} }
// Save inventory snapshots and detect visibility events // Save inventory snapshots and detect visibility events
@@ -181,7 +155,6 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
payloadId: payloadResult?.id || null, payloadId: payloadResult?.id || null,
payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0, payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0,
baselineSaved: !!payloadResult, baselineSaved: !!payloadResult,
wasabiPath,
snapshotCount, snapshotCount,
eventCount, eventCount,
storeInfo: result.store ? { storeInfo: result.store ? {

View File

@@ -33,7 +33,6 @@ import { saveDailyBaseline } from '../../utils/payload-storage';
import { taskService } from '../task-service'; import { taskService } from '../task-service';
import { saveInventorySnapshots } from '../../services/inventory-snapshots'; import { saveInventorySnapshots } from '../../services/inventory-snapshots';
import { detectVisibilityEvents } from '../../services/visibility-events'; import { detectVisibilityEvents } from '../../services/visibility-events';
import { storePayload as storeWasabiPayload } from '../../services/wasabi-storage';
export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<TaskResult> { export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<TaskResult> {
const { pool, task, crawlRotator } = ctx; const { pool, task, crawlRotator } = ctx;
@@ -51,7 +50,7 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
try { try {
// Load dispensary // Load dispensary
const dispResult = await pool.query( const dispResult = await pool.query(
`SELECT id, name, menu_url, platform_dispensary_id, menu_type, platform, state `SELECT id, name, menu_url, platform_dispensary_id, menu_type, platform
FROM dispensaries WHERE id = $1`, FROM dispensaries WHERE id = $1`,
[dispensaryId] [dispensaryId]
); );
@@ -117,32 +116,7 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
dispensaryId, dispensaryId,
}; };
// Archive to Wasabi S3 (if configured) // Save daily baseline to filesystem (only in 12:01-3:00 AM window, once per day)
let wasabiPath: string | null = null;
try {
const wasabiResult = await storeWasabiPayload(
dispensaryId,
dispensary.state || 'XX',
'treez',
rawPayload,
{
taskId: String(task.id),
storeId: result.storeId || 'unknown',
productCount: String(result.products.length),
}
);
wasabiPath = wasabiResult.path;
const compressionRatio = Math.round((1 - wasabiResult.compressedBytes / wasabiResult.sizeBytes) * 100);
console.log(`[TreezProductDiscovery] Archived to Wasabi: ${wasabiPath} (${(wasabiResult.compressedBytes / 1024).toFixed(1)}KB, ${compressionRatio}% compression)`);
} catch (wasabiErr: any) {
if (wasabiErr.message?.includes('not configured')) {
console.log(`[TreezProductDiscovery] Wasabi not configured, skipping archive`);
} else {
console.warn(`[TreezProductDiscovery] Wasabi archive failed: ${wasabiErr.message}`);
}
}
// Save daily baseline to PostgreSQL (only in 12:01-3:00 AM window, once per day)
const payloadResult = await saveDailyBaseline( const payloadResult = await saveDailyBaseline(
pool, pool,
dispensaryId, dispensaryId,
@@ -156,7 +130,7 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
if (payloadResult) { if (payloadResult) {
console.log(`[TreezProductDiscovery] Saved daily baseline ${payloadResult.id} (${Math.round(payloadResult.sizeBytes / 1024)}KB)`); console.log(`[TreezProductDiscovery] Saved daily baseline ${payloadResult.id} (${Math.round(payloadResult.sizeBytes / 1024)}KB)`);
} else { } else {
console.log(`[TreezProductDiscovery] Skipped PostgreSQL baseline (outside window or already exists)`); console.log(`[TreezProductDiscovery] Skipped full payload save (outside baseline window or already exists)`);
} }
// Save inventory snapshots and detect visibility events // Save inventory snapshots and detect visibility events
@@ -197,7 +171,6 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
payloadId: payloadResult?.id || null, payloadId: payloadResult?.id || null,
payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0, payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0,
baselineSaved: !!payloadResult, baselineSaved: !!payloadResult,
wasabiPath,
snapshotCount, snapshotCount,
eventCount, eventCount,
storeId: result.storeId, storeId: result.storeId,

View File

@@ -194,21 +194,6 @@ class TaskService {
return null; return null;
} }
// ENFORCE MAX TASK LIMIT - check before ANY claiming path
const workerCheck = await pool.query(`
SELECT session_task_count, COALESCE(session_max_tasks, 5) as max_tasks
FROM worker_registry
WHERE worker_id = $1
`, [workerId]);
if (workerCheck.rows.length > 0) {
const { session_task_count, max_tasks } = workerCheck.rows[0];
if (session_task_count >= max_tasks) {
console.log(`[TaskService] Worker ${workerId} at max capacity (${session_task_count}/${max_tasks})`);
return null;
}
}
if (role) { if (role) {
// Role-specific claiming - use the SQL function with preflight capabilities // Role-specific claiming - use the SQL function with preflight capabilities
const result = await pool.query( const result = await pool.query(
@@ -218,24 +203,7 @@ class TaskService {
return (result.rows[0] as WorkerTask) || null; return (result.rows[0] as WorkerTask) || null;
} }
// Role-agnostic claiming - MUST still enforce geo session + state matching // Role-agnostic claiming - claim ANY pending task matching worker capabilities
// First verify worker has a valid geo session
const geoCheck = await pool.query(`
SELECT current_state,
(geo_session_started_at IS NOT NULL
AND geo_session_started_at > NOW() - INTERVAL '60 minutes') as session_valid
FROM worker_registry
WHERE worker_id = $1
`, [workerId]);
if (geoCheck.rows.length === 0 || !geoCheck.rows[0].session_valid || !geoCheck.rows[0].current_state) {
console.log(`[TaskService] Worker ${workerId} has no valid geo session - cannot claim tasks`);
return null;
}
const workerState = geoCheck.rows[0].current_state;
// Claim task matching worker's state and method capabilities
const result = await pool.query(` const result = await pool.query(`
UPDATE worker_tasks UPDATE worker_tasks
SET SET
@@ -243,39 +211,27 @@ class TaskService {
worker_id = $1, worker_id = $1,
claimed_at = NOW() claimed_at = NOW()
WHERE id = ( WHERE id = (
SELECT wt.id FROM worker_tasks wt SELECT id FROM worker_tasks
JOIN dispensaries d ON wt.dispensary_id = d.id WHERE status = 'pending'
WHERE wt.status = 'pending' AND (scheduled_for IS NULL OR scheduled_for <= NOW())
AND (wt.scheduled_for IS NULL OR wt.scheduled_for <= NOW())
-- GEO FILTER: Task's dispensary must match worker's state
AND d.state = $4
-- Method compatibility: worker must have passed the required preflight -- Method compatibility: worker must have passed the required preflight
AND ( AND (
wt.method IS NULL -- No preference, any worker can claim method IS NULL -- No preference, any worker can claim
OR (wt.method = 'curl' AND $2 = TRUE) OR (method = 'curl' AND $2 = TRUE)
OR (wt.method = 'http' AND $3 = TRUE) OR (method = 'http' AND $3 = TRUE)
) )
-- Exclude stores that already have an active task -- Exclude stores that already have an active task
AND (wt.dispensary_id IS NULL OR wt.dispensary_id NOT IN ( AND (dispensary_id IS NULL OR dispensary_id NOT IN (
SELECT dispensary_id FROM worker_tasks SELECT dispensary_id FROM worker_tasks
WHERE status IN ('claimed', 'running') WHERE status IN ('claimed', 'running')
AND dispensary_id IS NOT NULL AND dispensary_id IS NOT NULL
)) ))
ORDER BY wt.priority DESC, wt.created_at ASC ORDER BY priority DESC, created_at ASC
LIMIT 1 LIMIT 1
FOR UPDATE SKIP LOCKED FOR UPDATE SKIP LOCKED
) )
RETURNING * RETURNING *
`, [workerId, curlPassed, httpPassed, workerState]); `, [workerId, curlPassed, httpPassed]);
// Increment session_task_count if task was claimed
if (result.rows[0]) {
await pool.query(`
UPDATE worker_registry
SET session_task_count = session_task_count + 1
WHERE worker_id = $1
`, [workerId]);
}
return (result.rows[0] as WorkerTask) || null; return (result.rows[0] as WorkerTask) || null;
} }
@@ -305,24 +261,28 @@ class TaskService {
} }
/** /**
* Mark a task as completed and remove from pool * Mark a task as completed with verification
* Completed tasks are deleted - only failed tasks stay in the pool for retry/review * Returns true if completion was verified in DB, false otherwise
* Returns true if task was successfully deleted
*/ */
async completeTask(taskId: number, result?: Record<string, unknown>): Promise<boolean> { async completeTask(taskId: number, result?: Record<string, unknown>): Promise<boolean> {
// Delete the completed task from the pool await pool.query(
// Only failed tasks stay in the table for retry/review `UPDATE worker_tasks
const deleteResult = await pool.query( SET status = 'completed', completed_at = NOW(), result = $2
`DELETE FROM worker_tasks WHERE id = $1 RETURNING id`, WHERE id = $1`,
[taskId, result ? JSON.stringify(result) : null]
);
// Verify completion was recorded
const verify = await pool.query(
`SELECT status FROM worker_tasks WHERE id = $1`,
[taskId] [taskId]
); );
if (deleteResult.rowCount === 0) { if (verify.rows[0]?.status !== 'completed') {
console.error(`[TaskService] Task ${taskId} completion FAILED - task not found or already deleted`); console.error(`[TaskService] Task ${taskId} completion NOT VERIFIED - DB shows status: ${verify.rows[0]?.status}`);
return false; return false;
} }
console.log(`[TaskService] Task ${taskId} completed and removed from pool`);
return true; return true;
} }
@@ -391,7 +351,7 @@ class TaskService {
* Hard failures: Auto-retry up to MAX_RETRIES with exponential backoff * Hard failures: Auto-retry up to MAX_RETRIES with exponential backoff
*/ */
async failTask(taskId: number, errorMessage: string): Promise<boolean> { async failTask(taskId: number, errorMessage: string): Promise<boolean> {
const MAX_RETRIES = 5; const MAX_RETRIES = 3;
const isSoft = this.isSoftFailure(errorMessage); const isSoft = this.isSoftFailure(errorMessage);
// Get current retry count // Get current retry count
@@ -530,15 +490,7 @@ class TaskService {
${poolJoin} ${poolJoin}
LEFT JOIN worker_registry w ON w.worker_id = t.worker_id LEFT JOIN worker_registry w ON w.worker_id = t.worker_id
${whereClause} ${whereClause}
ORDER BY ORDER BY t.created_at DESC
CASE t.status
WHEN 'active' THEN 1
WHEN 'pending' THEN 2
WHEN 'failed' THEN 3
WHEN 'completed' THEN 4
ELSE 5
END,
t.created_at DESC
LIMIT ${limit} OFFSET ${offset}`, LIMIT ${limit} OFFSET ${offset}`,
params params
); );
@@ -1049,31 +1001,9 @@ class TaskService {
const claimedAt = task.claimed_at || task.created_at; const claimedAt = task.claimed_at || task.created_at;
switch (task.role) { switch (task.role) {
case 'product_refresh':
case 'product_discovery': { case 'product_discovery': {
// For product_discovery, verify inventory snapshots were saved (always happens) // Verify payload was saved to raw_crawl_payloads after task was claimed
// Note: raw_crawl_payloads only saved during baseline window, so check snapshots instead
const snapshotResult = await pool.query(
`SELECT COUNT(*)::int as count
FROM inventory_snapshots
WHERE dispensary_id = $1
AND captured_at > $2`,
[task.dispensary_id, claimedAt]
);
const snapshotCount = snapshotResult.rows[0]?.count || 0;
if (snapshotCount === 0) {
return {
verified: false,
reason: `No inventory snapshots found for dispensary ${task.dispensary_id} after ${claimedAt}`
};
}
return { verified: true };
}
case 'product_refresh': {
// For product_refresh, verify payload was saved to raw_crawl_payloads
const payloadResult = await pool.query( const payloadResult = await pool.query(
`SELECT id, product_count, fetched_at `SELECT id, product_count, fetched_at
FROM raw_crawl_payloads FROM raw_crawl_payloads

View File

@@ -131,9 +131,9 @@ const API_BASE_URL = process.env.API_BASE_URL || 'http://localhost:3010';
// Browser tasks (Puppeteer) use ~400MB RAM each. With 2GB pod limit: // Browser tasks (Puppeteer) use ~400MB RAM each. With 2GB pod limit:
// - 3 browsers = ~1.3GB = SAFE // - 3 browsers = ~1.3GB = SAFE
// - 4 browsers = ~1.7GB = RISKY // - 4 browsers = ~1.7GB = RISKY
// - 5 browsers = ~2.0GB = AT LIMIT (monitor memory closely) // - 5+ browsers = OOM CRASH
// See: docs/WORKER_TASK_ARCHITECTURE.md#browser-task-memory-limits // See: docs/WORKER_TASK_ARCHITECTURE.md#browser-task-memory-limits
const MAX_CONCURRENT_TASKS = parseInt(process.env.MAX_CONCURRENT_TASKS || '5'); const MAX_CONCURRENT_TASKS = parseInt(process.env.MAX_CONCURRENT_TASKS || '3');
// When heap memory usage exceeds this threshold (as decimal 0.0-1.0), stop claiming new tasks // When heap memory usage exceeds this threshold (as decimal 0.0-1.0), stop claiming new tasks
// Default 85% - gives headroom before OOM // Default 85% - gives headroom before OOM

View File

@@ -417,26 +417,23 @@ export async function listPayloadMetadata(
sizeBytes: number; sizeBytes: number;
sizeBytesRaw: number; sizeBytesRaw: number;
fetchedAt: Date; fetchedAt: Date;
dispensary_name: string | null;
city: string | null;
state: string | null;
}>> { }>> {
const conditions: string[] = []; const conditions: string[] = [];
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
if (options.dispensaryId) { if (options.dispensaryId) {
conditions.push(`rcp.dispensary_id = $${paramIndex++}`); conditions.push(`dispensary_id = $${paramIndex++}`);
params.push(options.dispensaryId); params.push(options.dispensaryId);
} }
if (options.startDate) { if (options.startDate) {
conditions.push(`rcp.fetched_at >= $${paramIndex++}`); conditions.push(`fetched_at >= $${paramIndex++}`);
params.push(options.startDate); params.push(options.startDate);
} }
if (options.endDate) { if (options.endDate) {
conditions.push(`rcp.fetched_at <= $${paramIndex++}`); conditions.push(`fetched_at <= $${paramIndex++}`);
params.push(options.endDate); params.push(options.endDate);
} }
@@ -448,21 +445,17 @@ export async function listPayloadMetadata(
const result = await pool.query(` const result = await pool.query(`
SELECT SELECT
rcp.id, id,
rcp.dispensary_id, dispensary_id,
rcp.crawl_run_id, crawl_run_id,
rcp.storage_path, storage_path,
rcp.product_count, product_count,
rcp.size_bytes, size_bytes,
rcp.size_bytes_raw, size_bytes_raw,
rcp.fetched_at, fetched_at
d.name as dispensary_name, FROM raw_crawl_payloads
d.city,
d.state
FROM raw_crawl_payloads rcp
LEFT JOIN dispensaries d ON d.id = rcp.dispensary_id
${whereClause} ${whereClause}
ORDER BY rcp.fetched_at DESC ORDER BY fetched_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex} LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, params); `, params);
@@ -474,10 +467,7 @@ export async function listPayloadMetadata(
productCount: row.product_count, productCount: row.product_count,
sizeBytes: row.size_bytes, sizeBytes: row.size_bytes,
sizeBytesRaw: row.size_bytes_raw, sizeBytesRaw: row.size_bytes_raw,
fetchedAt: row.fetched_at, fetchedAt: row.fetched_at
dispensary_name: row.dispensary_name,
city: row.city,
state: row.state
})); }));
} }

View File

@@ -1,36 +1,29 @@
/** /**
* Provider Display Names * Provider Display Names
* *
* Maps internal menu_type values to display labels. * Maps internal provider identifiers to safe display labels.
* - standalone/embedded → dutchie (both are Dutchie platform) * Internal identifiers (menu_type, product_provider, crawler_type) remain unchanged.
* - treez → treez * Only the display label shown to users is transformed.
* - jane/iheartjane → jane
*/ */
export const ProviderDisplayNames: Record<string, string> = { export const ProviderDisplayNames: Record<string, string> = {
// Dutchie (standalone and embedded are both Dutchie) // All menu providers map to anonymous "Menu Feed" label
dutchie: 'dutchie', dutchie: 'Menu Feed',
standalone: 'dutchie', treez: 'Menu Feed',
embedded: 'dutchie', jane: 'Menu Feed',
iheartjane: 'Menu Feed',
// Other platforms blaze: 'Menu Feed',
treez: 'treez', flowhub: 'Menu Feed',
jane: 'jane', weedmaps: 'Menu Feed',
iheartjane: 'jane', leafly: 'Menu Feed',
leaflogix: 'Menu Feed',
// Future platforms tymber: 'Menu Feed',
blaze: 'blaze', dispense: 'Menu Feed',
flowhub: 'flowhub',
weedmaps: 'weedmaps',
leafly: 'leafly',
leaflogix: 'leaflogix',
tymber: 'tymber',
dispense: 'dispense',
// Catch-all // Catch-all
unknown: 'unknown', unknown: 'Menu Feed',
default: 'unknown', default: 'Menu Feed',
'': 'unknown', '': 'Menu Feed',
}; };
/** /**

View File

@@ -51,9 +51,6 @@ import { ProxyManagement } from './pages/ProxyManagement';
import TasksDashboard from './pages/TasksDashboard'; import TasksDashboard from './pages/TasksDashboard';
import { PayloadsDashboard } from './pages/PayloadsDashboard'; import { PayloadsDashboard } from './pages/PayloadsDashboard';
import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard'; import { ScraperOverviewDashboard } from './pages/ScraperOverviewDashboard';
import { HighFrequencyManager } from './pages/HighFrequencyManager';
import { VisibilityEventsDashboard } from './pages/VisibilityEventsDashboard';
import { InventorySnapshotsDashboard } from './pages/InventorySnapshotsDashboard';
import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator'; import { SeoOrchestrator } from './pages/admin/seo/SeoOrchestrator';
import { StatePage } from './pages/public/StatePage'; import { StatePage } from './pages/public/StatePage';
import { SeoPage } from './pages/public/SeoPage'; import { SeoPage } from './pages/public/SeoPage';
@@ -138,10 +135,6 @@ export default function App() {
<Route path="/payloads" element={<PrivateRoute><PayloadsDashboard /></PrivateRoute>} /> <Route path="/payloads" element={<PrivateRoute><PayloadsDashboard /></PrivateRoute>} />
{/* Scraper Overview Dashboard (new primary) */} {/* Scraper Overview Dashboard (new primary) */}
<Route path="/scraper/overview" element={<PrivateRoute><ScraperOverviewDashboard /></PrivateRoute>} /> <Route path="/scraper/overview" element={<PrivateRoute><ScraperOverviewDashboard /></PrivateRoute>} />
{/* Inventory Tracking routes */}
<Route path="/inventory/high-frequency" element={<PrivateRoute><HighFrequencyManager /></PrivateRoute>} />
<Route path="/inventory/events" element={<PrivateRoute><VisibilityEventsDashboard /></PrivateRoute>} />
<Route path="/inventory/snapshots" element={<PrivateRoute><InventorySnapshotsDashboard /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -1,141 +0,0 @@
/**
* Event Type Badge Component
*
* Color-coded badge for visibility event types.
* Used in VisibilityEventsDashboard and brand event views.
*/
import React from 'react';
export type EventType = 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
interface EventTypeConfig {
label: string;
shortLabel: string;
bgColor: string;
textColor: string;
icon: string;
description: string;
}
const EVENT_TYPE_CONFIG: Record<EventType, EventTypeConfig> = {
oos: {
label: 'Out of Stock',
shortLabel: 'OOS',
bgColor: 'bg-red-600',
textColor: 'text-white',
icon: '!',
description: 'Product went out of stock',
},
back_in_stock: {
label: 'Back in Stock',
shortLabel: 'In Stock',
bgColor: 'bg-green-600',
textColor: 'text-white',
icon: '+',
description: 'Product returned to stock',
},
brand_dropped: {
label: 'Brand Dropped',
shortLabel: 'Dropped',
bgColor: 'bg-orange-600',
textColor: 'text-white',
icon: '-',
description: 'Brand no longer at this store',
},
brand_added: {
label: 'Brand Added',
shortLabel: 'Added',
bgColor: 'bg-blue-600',
textColor: 'text-white',
icon: '+',
description: 'New brand at this store',
},
price_change: {
label: 'Price Change',
shortLabel: 'Price',
bgColor: 'bg-yellow-600',
textColor: 'text-black',
icon: '$',
description: 'Significant price change (>5%)',
},
};
interface EventTypeBadgeProps {
type: EventType;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
showIcon?: boolean;
className?: string;
}
export function EventTypeBadge({
type,
size = 'md',
showLabel = true,
showIcon = true,
className = '',
}: EventTypeBadgeProps) {
const config = EVENT_TYPE_CONFIG[type];
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
};
return (
<span
className={`
inline-flex items-center gap-1 rounded-full font-medium
${config.bgColor} ${config.textColor}
${sizeClasses[size]}
${className}
`}
title={config.description}
>
{showIcon && (
<span className="font-bold">{config.icon}</span>
)}
{showLabel && (
<span>{size === 'sm' ? config.shortLabel : config.label}</span>
)}
</span>
);
}
/**
* Get event type configuration
*/
export function getEventTypeConfig(type: EventType): EventTypeConfig {
return EVENT_TYPE_CONFIG[type];
}
/**
* Get all event types for filtering
*/
export function getAllEventTypes(): { value: EventType; label: string }[] {
return Object.entries(EVENT_TYPE_CONFIG).map(([value, config]) => ({
value: value as EventType,
label: config.label,
}));
}
/**
* Format price change for display
*/
export function formatPriceChange(
previousPrice: number | null,
newPrice: number | null,
pctChange: number | null
): string {
if (previousPrice === null || newPrice === null) return 'N/A';
const diff = newPrice - previousPrice;
const sign = diff > 0 ? '+' : '';
const pct = pctChange !== null ? ` (${sign}${pctChange.toFixed(1)}%)` : '';
return `$${previousPrice.toFixed(2)} -> $${newPrice.toFixed(2)}${pct}`;
}
export default EventTypeBadge;

View File

@@ -1,93 +0,0 @@
/**
* Interval Dropdown Component
*
* Dropdown selector for high-frequency crawl intervals.
* Used in HighFrequencyManager and DispensaryDetail pages.
*/
import React from 'react';
interface IntervalOption {
value: number;
label: string;
}
const INTERVAL_OPTIONS: IntervalOption[] = [
{ value: 15, label: '15 minutes' },
{ value: 30, label: '30 minutes' },
{ value: 60, label: '1 hour' },
{ value: 120, label: '2 hours' },
{ value: 240, label: '4 hours' },
];
interface IntervalDropdownProps {
value: number | null;
onChange: (value: number | null) => void;
includeNone?: boolean;
disabled?: boolean;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
export function IntervalDropdown({
value,
onChange,
includeNone = true,
disabled = false,
className = '',
size = 'md',
}: IntervalDropdownProps) {
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-3 py-2 text-base',
lg: 'px-4 py-3 text-lg',
};
return (
<select
value={value ?? ''}
onChange={(e) => {
const val = e.target.value;
onChange(val === '' ? null : parseInt(val, 10));
}}
disabled={disabled}
className={`
${sizeClasses[size]}
bg-gray-800 border border-gray-700 rounded-md text-white
focus:ring-2 focus:ring-blue-500 focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed
${className}
`}
>
{includeNone && <option value="">No high-frequency</option>}
{INTERVAL_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
/**
* Format interval minutes to human-readable string
*/
export function formatInterval(minutes: number | null): string {
if (minutes === null) return 'Standard';
if (minutes < 60) return `${minutes}m`;
const hours = minutes / 60;
return hours === 1 ? '1 hour' : `${hours} hours`;
}
/**
* Get interval badge color
*/
export function getIntervalColor(minutes: number | null): string {
if (minutes === null) return 'bg-gray-600';
if (minutes <= 15) return 'bg-red-600';
if (minutes <= 30) return 'bg-orange-600';
if (minutes <= 60) return 'bg-yellow-600';
return 'bg-green-600';
}
export default IntervalDropdown;

View File

@@ -24,10 +24,7 @@ import {
Key, Key,
Bot, Bot,
ListChecks, ListChecks,
Database, Database
Clock,
Bell,
Package
} from 'lucide-react'; } from 'lucide-react';
interface LayoutProps { interface LayoutProps {
@@ -179,12 +176,6 @@ export function Layout({ children }: LayoutProps) {
<NavLink to="/analytics/clicks" icon={<MousePointerClick className="w-4 h-4" />} label="Click Analytics" isActive={isActive('/analytics/clicks')} /> <NavLink to="/analytics/clicks" icon={<MousePointerClick className="w-4 h-4" />} label="Click Analytics" isActive={isActive('/analytics/clicks')} />
</NavSection> </NavSection>
<NavSection title="Inventory">
<NavLink to="/inventory/high-frequency" icon={<Clock className="w-4 h-4" />} label="High-Frequency" isActive={isActive('/inventory/high-frequency')} />
<NavLink to="/inventory/events" icon={<Bell className="w-4 h-4" />} label="Visibility Events" isActive={isActive('/inventory/events')} />
<NavLink to="/inventory/snapshots" icon={<Package className="w-4 h-4" />} label="Snapshots" isActive={isActive('/inventory/snapshots')} />
</NavSection>
<NavSection title="Admin"> <NavSection title="Admin">
<NavLink to="/admin/orchestrator" icon={<Activity className="w-4 h-4" />} label="Orchestrator" isActive={isActive('/admin/orchestrator')} /> <NavLink to="/admin/orchestrator" icon={<Activity className="w-4 h-4" />} label="Orchestrator" isActive={isActive('/admin/orchestrator')} />
<NavLink to="/users" icon={<UserCog className="w-4 h-4" />} label="Users" isActive={isActive('/users')} /> <NavLink to="/users" icon={<UserCog className="w-4 h-4" />} label="Users" isActive={isActive('/users')} />

View File

@@ -1,128 +0,0 @@
/**
* Stock Status Badge Component
*
* Color-coded badge for product stock status.
* Used in inventory views and product intelligence displays.
*/
import React from 'react';
interface StockStatusBadgeProps {
inStock: boolean;
quantity?: number | null;
daysUntilOOS?: number | null;
size?: 'sm' | 'md' | 'lg';
showQuantity?: boolean;
className?: string;
}
export function StockStatusBadge({
inStock,
quantity,
daysUntilOOS,
size = 'md',
showQuantity = false,
className = '',
}: StockStatusBadgeProps) {
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
};
// Determine badge color based on stock status and days until OOS
let bgColor = 'bg-gray-600';
let textColor = 'text-white';
let label = 'Unknown';
if (!inStock) {
bgColor = 'bg-red-600';
label = 'Out of Stock';
} else if (daysUntilOOS != null && daysUntilOOS <= 3) {
bgColor = 'bg-orange-600';
label = `Low (${daysUntilOOS}d)`;
} else if (daysUntilOOS != null && daysUntilOOS <= 7) {
bgColor = 'bg-yellow-600';
textColor = 'text-black';
label = `Moderate (${daysUntilOOS}d)`;
} else if (inStock) {
bgColor = 'bg-green-600';
label = 'In Stock';
}
// Add quantity if requested
if (showQuantity && quantity !== null && quantity !== undefined) {
label = `${label} (${quantity})`;
}
return (
<span
className={`
inline-flex items-center rounded-full font-medium
${bgColor} ${textColor}
${sizeClasses[size]}
${className}
`}
>
{label}
</span>
);
}
/**
* Days Until Stock Out Indicator
*/
interface DaysUntilOOSProps {
days: number | null;
className?: string;
}
export function DaysUntilOOS({ days, className = '' }: DaysUntilOOSProps) {
if (days === null) {
return (
<span className={`text-gray-500 ${className}`}>-</span>
);
}
let color = 'text-green-500';
if (days <= 3) {
color = 'text-red-500';
} else if (days <= 7) {
color = 'text-yellow-500';
}
return (
<span className={`font-medium ${color} ${className}`}>
{days}d
</span>
);
}
/**
* Stock Diff Indicator
* Shows the change in stock over a period (e.g., 120 days)
*/
interface StockDiffProps {
diff: number;
className?: string;
}
export function StockDiff({ diff, className = '' }: StockDiffProps) {
let color = 'text-gray-500';
let sign = '';
if (diff > 0) {
color = 'text-green-500';
sign = '+';
} else if (diff < 0) {
color = 'text-red-500';
}
return (
<span className={`font-medium ${color} ${className}`}>
{sign}{diff}
</span>
);
}
export default StockStatusBadge;

View File

@@ -1,122 +0,0 @@
/**
* Velocity Tier Badge Component
*
* Color-coded badge for SKU velocity tiers.
* Used in product intelligence and SKU velocity views.
*/
import React from 'react';
export type VelocityTier = 'hot' | 'steady' | 'slow' | 'stale';
interface TierConfig {
label: string;
bgColor: string;
textColor: string;
icon: string;
description: string;
unitsPerDay: string;
}
const TIER_CONFIG: Record<VelocityTier, TierConfig> = {
hot: {
label: 'Hot',
bgColor: 'bg-red-600',
textColor: 'text-white',
icon: '\u{1F525}', // Fire emoji
description: 'High velocity - 5+ units/day',
unitsPerDay: '5+',
},
steady: {
label: 'Steady',
bgColor: 'bg-green-600',
textColor: 'text-white',
icon: '\u{2705}', // Checkmark
description: 'Moderate velocity - 1-5 units/day',
unitsPerDay: '1-5',
},
slow: {
label: 'Slow',
bgColor: 'bg-yellow-600',
textColor: 'text-black',
icon: '\u{1F422}', // Turtle
description: 'Low velocity - 0.1-1 units/day',
unitsPerDay: '0.1-1',
},
stale: {
label: 'Stale',
bgColor: 'bg-gray-600',
textColor: 'text-white',
icon: '\u{1F4A4}', // Zzz
description: 'No movement - <0.1 units/day',
unitsPerDay: '<0.1',
},
};
interface VelocityTierBadgeProps {
tier: VelocityTier;
size?: 'sm' | 'md' | 'lg';
showIcon?: boolean;
className?: string;
}
export function VelocityTierBadge({
tier,
size = 'md',
showIcon = false,
className = '',
}: VelocityTierBadgeProps) {
const config = TIER_CONFIG[tier];
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-sm',
lg: 'px-3 py-1.5 text-base',
};
return (
<span
className={`
inline-flex items-center gap-1 rounded-full font-medium
${config.bgColor} ${config.textColor}
${sizeClasses[size]}
${className}
`}
title={config.description}
>
{showIcon && <span>{config.icon}</span>}
<span>{config.label}</span>
</span>
);
}
/**
* Get tier configuration
*/
export function getTierConfig(tier: VelocityTier): TierConfig {
return TIER_CONFIG[tier];
}
/**
* Get all velocity tiers for filtering
*/
export function getAllVelocityTiers(): { value: VelocityTier; label: string; description: string }[] {
return Object.entries(TIER_CONFIG).map(([value, config]) => ({
value: value as VelocityTier,
label: config.label,
description: config.description,
}));
}
/**
* Determine velocity tier from avg daily units
*/
export function getVelocityTier(avgDailyUnits: number | null): VelocityTier {
if (avgDailyUnits === null) return 'stale';
if (avgDailyUnits >= 5) return 'hot';
if (avgDailyUnits >= 1) return 'steady';
if (avgDailyUnits >= 0.1) return 'slow';
return 'stale';
}
export default VelocityTierBadge;

View File

@@ -3231,399 +3231,6 @@ class ApiClient {
}; };
}>(`/api/payloads/store/${dispensaryId}/diff${query ? '?' + query : ''}`); }>(`/api/payloads/store/${dispensaryId}/diff${query ? '?' + query : ''}`);
} }
// ============================================================
// SALES ANALYTICS API (Materialized Views)
// Part of Real-Time Inventory Tracking feature
// ============================================================
async getDailySalesEstimates(params?: {
state?: string;
brand?: string;
category?: string;
dispensary_id?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.brand) searchParams.append('brand', params.brand);
if (params?.category) searchParams.append('category', params.category);
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
if (params?.limit) searchParams.append('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{
success: boolean;
data: DailySalesEstimate[];
count: number;
}>(`/api/sales-analytics/daily-sales${query ? '?' + query : ''}`);
}
async getBrandMarketShare(params?: {
state?: string;
brand?: string;
min_penetration?: number;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.brand) searchParams.append('brand', params.brand);
if (params?.min_penetration) searchParams.append('min_penetration', String(params.min_penetration));
if (params?.limit) searchParams.append('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{
success: boolean;
data: BrandMarketShare[];
count: number;
}>(`/api/sales-analytics/brand-market-share${query ? '?' + query : ''}`);
}
async getSkuVelocity(params?: {
state?: string;
brand?: string;
category?: string;
dispensary_id?: number;
tier?: 'hot' | 'steady' | 'slow' | 'stale';
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.brand) searchParams.append('brand', params.brand);
if (params?.category) searchParams.append('category', params.category);
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
if (params?.tier) searchParams.append('tier', params.tier);
if (params?.limit) searchParams.append('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{
success: boolean;
data: SkuVelocity[];
count: number;
}>(`/api/sales-analytics/sku-velocity${query ? '?' + query : ''}`);
}
async getStorePerformance(params?: {
state?: string;
sort_by?: 'revenue' | 'units' | 'brands' | 'skus';
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.sort_by) searchParams.append('sort_by', params.sort_by);
if (params?.limit) searchParams.append('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{
success: boolean;
data: StorePerformance[];
count: number;
}>(`/api/sales-analytics/store-performance${query ? '?' + query : ''}`);
}
async getCategoryTrends(params?: {
state?: string;
category?: string;
weeks?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.category) searchParams.append('category', params.category);
if (params?.weeks) searchParams.append('weeks', String(params.weeks));
const query = searchParams.toString();
return this.request<{
success: boolean;
data: CategoryTrend[];
count: number;
}>(`/api/sales-analytics/category-trends${query ? '?' + query : ''}`);
}
async getProductIntelligence(params?: {
state?: string;
brand?: string;
category?: string;
dispensary_id?: number;
in_stock?: boolean;
low_stock?: boolean;
recent_oos?: boolean;
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.brand) searchParams.append('brand', params.brand);
if (params?.category) searchParams.append('category', params.category);
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
if (params?.in_stock !== undefined) searchParams.append('in_stock', String(params.in_stock));
if (params?.low_stock !== undefined) searchParams.append('low_stock', String(params.low_stock));
if (params?.recent_oos !== undefined) searchParams.append('recent_oos', String(params.recent_oos));
if (params?.limit) searchParams.append('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{
success: boolean;
data: ProductIntelligence[];
count: number;
}>(`/api/sales-analytics/product-intelligence${query ? '?' + query : ''}`);
}
async getTopBrands(params?: {
state?: string;
window?: '7d' | '30d' | '90d' | '1y' | 'all';
limit?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.window) searchParams.append('window', params.window);
if (params?.limit) searchParams.append('limit', String(params.limit));
const query = searchParams.toString();
return this.request<{
success: boolean;
data: TopBrand[];
count: number;
}>(`/api/sales-analytics/top-brands${query ? '?' + query : ''}`);
}
async refreshSalesAnalytics() {
return this.request<{
success: boolean;
data: Array<{ view_name: string; rows_affected: number }>;
}>('/api/sales-analytics/refresh', {
method: 'POST',
});
}
async getSalesAnalyticsStats() {
return this.request<{
success: boolean;
data: Record<string, number>;
}>('/api/sales-analytics/stats');
}
// ============================================================
// INVENTORY SNAPSHOTS & VISIBILITY EVENTS API
// Part of Real-Time Inventory Tracking feature
// ============================================================
async getInventorySnapshots(params?: {
dispensary_id?: number;
product_id?: string;
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
if (params?.product_id) searchParams.append('product_id', params.product_id);
if (params?.limit) searchParams.append('limit', String(params.limit));
if (params?.offset) searchParams.append('offset', String(params.offset));
const query = searchParams.toString();
return this.request<{
success: boolean;
snapshots: InventorySnapshot[];
count: number;
}>(`/api/tasks/inventory-snapshots${query ? '?' + query : ''}`);
}
async getVisibilityEvents(params?: {
dispensary_id?: number;
brand?: string;
event_type?: 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.dispensary_id) searchParams.append('dispensary_id', String(params.dispensary_id));
if (params?.brand) searchParams.append('brand', params.brand);
if (params?.event_type) searchParams.append('event_type', params.event_type);
if (params?.limit) searchParams.append('limit', String(params.limit));
if (params?.offset) searchParams.append('offset', String(params.offset));
const query = searchParams.toString();
return this.request<{
success: boolean;
events: VisibilityEvent[];
count: number;
}>(`/api/tasks/visibility-events${query ? '?' + query : ''}`);
}
async acknowledgeVisibilityEvent(eventId: number) {
return this.request<{
success: boolean;
message: string;
}>(`/api/tasks/visibility-events/${eventId}/acknowledge`, {
method: 'POST',
});
}
async getBrandVisibilityEvents(brand: string, params?: {
state?: string;
event_type?: string;
limit?: number;
offset?: number;
}) {
const searchParams = new URLSearchParams();
if (params?.state) searchParams.append('state', params.state);
if (params?.event_type) searchParams.append('event_type', params.event_type);
if (params?.limit) searchParams.append('limit', String(params.limit));
if (params?.offset) searchParams.append('offset', String(params.offset));
const query = searchParams.toString();
return this.request<{
success: boolean;
events: BrandVisibilityEvent[];
count: number;
}>(`/api/brands/${encodeURIComponent(brand)}/events${query ? '?' + query : ''}`);
}
}
// ============================================================
// SALES ANALYTICS TYPES
// ============================================================
export interface DailySalesEstimate {
dispensary_id: number;
product_id: string;
brand_name: string | null;
category: string | null;
sale_date: string;
avg_price: number | null;
units_sold: number;
units_restocked: number;
revenue_estimate: number;
snapshot_count: number;
}
export interface BrandMarketShare {
brand_name: string;
state_code: string;
stores_carrying: number;
total_stores: number;
penetration_pct: number;
sku_count: number;
in_stock_skus: number;
avg_price: number | null;
calculated_at: string;
}
export interface SkuVelocity {
product_id: string;
brand_name: string | null;
category: string | null;
dispensary_id: number;
dispensary_name: string;
state_code: string;
total_units_30d: number;
total_revenue_30d: number;
days_with_sales: number;
avg_daily_units: number;
avg_price: number | null;
velocity_tier: 'hot' | 'steady' | 'slow' | 'stale';
calculated_at: string;
}
export interface StorePerformance {
dispensary_id: number;
dispensary_name: string;
city: string | null;
state_code: string;
total_revenue_30d: number;
total_units_30d: number;
total_skus: number;
in_stock_skus: number;
unique_brands: number;
unique_categories: number;
avg_price: number | null;
last_updated: string | null;
calculated_at: string;
}
export interface CategoryTrend {
category: string;
state_code: string;
week_start: string;
sku_count: number;
store_count: number;
total_units: number;
total_revenue: number;
avg_price: number | null;
calculated_at: string;
}
export interface ProductIntelligence {
dispensary_id: number;
dispensary_name: string;
state_code: string;
city: string | null;
sku: string;
product_name: string;
brand: string | null;
category: string | null;
is_in_stock: boolean;
stock_status: string | null;
stock_quantity: number | null;
price: number | null;
first_seen: string | null;
last_seen: string | null;
stock_diff_120: number;
days_since_oos: number | null;
days_until_stock_out: number | null;
avg_daily_units: number | null;
calculated_at: string;
}
export interface TopBrand {
brand_name: string;
total_revenue: number;
total_units: number;
store_count: number;
sku_count: number;
avg_price: number | null;
}
// ============================================================
// INVENTORY & VISIBILITY TYPES
// ============================================================
export interface InventorySnapshot {
id: number;
dispensary_id: number;
product_id: string;
platform: 'dutchie' | 'jane' | 'treez';
quantity_available: number | null;
is_below_threshold: boolean;
status: string | null;
price_rec: number | null;
price_med: number | null;
brand_name: string | null;
category: string | null;
product_name: string | null;
captured_at: string;
}
export interface VisibilityEvent {
id: number;
dispensary_id: number;
dispensary_name?: string;
product_id: string | null;
product_name: string | null;
brand_name: string | null;
event_type: 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
detected_at: string;
previous_quantity: number | null;
previous_price: number | null;
new_price: number | null;
price_change_pct: number | null;
platform: 'dutchie' | 'jane' | 'treez';
notified: boolean;
acknowledged_at: string | null;
}
export interface BrandVisibilityEvent {
id: number;
dispensary_id: number;
dispensary_name: string;
state_code: string | null;
product_id: string | null;
product_name: string | null;
brand_name: string;
event_type: 'oos' | 'back_in_stock' | 'brand_dropped' | 'brand_added' | 'price_change';
detected_at: string;
previous_price: number | null;
new_price: number | null;
price_change_pct: number | null;
platform: 'dutchie' | 'jane' | 'treez';
} }
// Type for task schedules // Type for task schedules
@@ -3662,8 +3269,6 @@ export interface PayloadMetadata {
sizeBytesRaw: number; sizeBytesRaw: number;
fetchedAt: string; fetchedAt: string;
dispensary_name?: string; dispensary_name?: string;
city?: string;
state?: string;
} }
// Type for high-frequency (per-store) schedules // Type for high-frequency (per-store) schedules

View File

@@ -1,36 +1,32 @@
/** /**
* Provider Display Names * Provider Display Names
* *
* Maps internal menu_type values to display labels. * Maps internal provider identifiers to safe display labels.
* - standalone/embedded → Dutchie (both are Dutchie platform) * Internal identifiers (menu_type, product_provider, crawler_type) remain unchanged.
* - treez → Treez * Only the display label shown to users is transformed.
* - jane/iheartjane → Jane *
* IMPORTANT: Raw provider names (dutchie, treez, jane, etc.) must NEVER
* be displayed directly in the UI. Always use this utility.
*/ */
export const ProviderDisplayNames: Record<string, string> = { export const ProviderDisplayNames: Record<string, string> = {
// Dutchie (standalone and embedded are both Dutchie) // All menu providers map to anonymous "Menu Feed" label
dutchie: 'dutchie', dutchie: 'Menu Feed',
standalone: 'dutchie', treez: 'Menu Feed',
embedded: 'dutchie', jane: 'Menu Feed',
iheartjane: 'Menu Feed',
// Other platforms blaze: 'Menu Feed',
treez: 'treez', flowhub: 'Menu Feed',
jane: 'jane', weedmaps: 'Menu Feed',
iheartjane: 'jane', leafly: 'Menu Feed',
leaflogix: 'Menu Feed',
// Future platforms tymber: 'Menu Feed',
blaze: 'blaze', dispense: 'Menu Feed',
flowhub: 'flowhub',
weedmaps: 'weedmaps',
leafly: 'leafly',
leaflogix: 'leaflogix',
tymber: 'tymber',
dispense: 'dispense',
// Catch-all // Catch-all
unknown: 'unknown', unknown: 'Menu Feed',
default: 'unknown', default: 'Menu Feed',
'': 'unknown', '': 'Menu Feed',
}; };
/** /**

View File

@@ -1,342 +0,0 @@
/**
* High-Frequency Manager Page
*
* View and manage stores with custom high-frequency crawl intervals.
* Part of Real-Time Inventory Tracking feature.
*/
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api, HighFrequencyStore } from '../lib/api';
import { IntervalDropdown, formatInterval, getIntervalColor } from '../components/IntervalDropdown';
import {
Clock,
Store,
RefreshCw,
Plus,
Trash2,
AlertCircle,
CheckCircle,
Search,
TrendingUp,
Package,
} from 'lucide-react';
interface Stats {
totalStores: number;
byInterval: Record<number, number>;
byPlatform: Record<string, number>;
nextDueCount: number;
}
export function HighFrequencyManager() {
const navigate = useNavigate();
const [stores, setStores] = useState<HighFrequencyStore[]>([]);
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [updating, setUpdating] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Load data
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
setLoading(true);
setError(null);
const data = await api.getHighFrequencySchedules();
setStores(data.stores || []);
setStats(data.stats || null);
} catch (err: any) {
console.error('Failed to load high-frequency schedules:', err);
setError(err.message || 'Failed to load data');
} finally {
setLoading(false);
}
};
const handleIntervalChange = async (dispensaryId: number, intervalMinutes: number | null) => {
try {
setUpdating(dispensaryId);
setError(null);
if (intervalMinutes === null) {
await api.removeHighFrequencyInterval(dispensaryId);
setSuccess('High-frequency scheduling removed');
} else {
await api.setHighFrequencyInterval(dispensaryId, intervalMinutes);
setSuccess(`Interval updated to ${formatInterval(intervalMinutes)}`);
}
// Reload data
await loadData();
// Clear success message after 3 seconds
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
console.error('Failed to update interval:', err);
setError(err.message || 'Failed to update interval');
} finally {
setUpdating(null);
}
};
const handleRemove = async (dispensaryId: number) => {
if (!confirm('Remove high-frequency scheduling for this store?')) return;
await handleIntervalChange(dispensaryId, null);
};
// Filter stores by search term
const filteredStores = stores.filter((store) =>
store.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Format timestamp
const formatTime = (ts: string | null) => {
if (!ts) return '-';
const date = new Date(ts);
return date.toLocaleString();
};
// Format relative time
const formatRelativeTime = (ts: string | null) => {
if (!ts) return '-';
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
};
if (loading) {
return (
<Layout>
<div className="text-center py-12">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-4 border-blue-500 border-t-transparent"></div>
<p className="mt-2 text-sm text-gray-400">Loading high-frequency schedules...</p>
</div>
</Layout>
);
}
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Clock className="h-6 w-6 text-blue-500" />
High-Frequency Manager
</h1>
<p className="text-gray-400 mt-1">
Manage stores with custom crawl intervals for real-time inventory tracking
</p>
</div>
<button
onClick={loadData}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
{/* Alerts */}
{error && (
<div className="mb-4 p-4 bg-red-900/50 border border-red-700 rounded-lg flex items-center gap-2 text-red-200">
<AlertCircle className="h-5 w-5" />
{error}
</div>
)}
{success && (
<div className="mb-4 p-4 bg-green-900/50 border border-green-700 rounded-lg flex items-center gap-2 text-green-200">
<CheckCircle className="h-5 w-5" />
{success}
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Store className="h-4 w-4" />
Total Stores
</div>
<div className="text-2xl font-bold text-white mt-1">{stats.totalStores}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Clock className="h-4 w-4" />
Next Due
</div>
<div className="text-2xl font-bold text-white mt-1">{stats.nextDueCount}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<TrendingUp className="h-4 w-4" />
15m Interval
</div>
<div className="text-2xl font-bold text-white mt-1">{stats.byInterval[15] || 0}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Package className="h-4 w-4" />
30m Interval
</div>
<div className="text-2xl font-bold text-white mt-1">{stats.byInterval[30] || 0}</div>
</div>
</div>
)}
{/* Search */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search stores..."
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
{/* Stores Table */}
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-900">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Store</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Platform</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Interval</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Next Crawl</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Last Crawl</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Changes (24h)</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{filteredStores.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-400">
{stores.length === 0
? 'No stores configured for high-frequency crawling'
: 'No stores match your search'}
</td>
</tr>
) : (
filteredStores.map((store) => (
<tr key={store.id} className="hover:bg-gray-700/50">
<td className="px-4 py-3">
<button
onClick={() => navigate(`/dispensaries/${store.id}`)}
className="text-blue-400 hover:text-blue-300 font-medium"
>
{store.name}
</button>
</td>
<td className="px-4 py-3">
<span className="px-2 py-1 bg-gray-700 rounded text-sm text-gray-300">
{store.menu_type}
</span>
</td>
<td className="px-4 py-3">
<IntervalDropdown
value={store.crawl_interval_minutes}
onChange={(val) => handleIntervalChange(store.id, val)}
disabled={updating === store.id}
size="sm"
includeNone={true}
/>
</td>
<td className="px-4 py-3 text-sm text-gray-300">
{store.next_crawl_at ? (
<span title={formatTime(store.next_crawl_at)}>
{formatRelativeTime(store.next_crawl_at)}
</span>
) : (
'-'
)}
</td>
<td className="px-4 py-3 text-sm text-gray-300">
{store.last_crawl_started_at ? (
<span title={formatTime(store.last_crawl_started_at)}>
{formatRelativeTime(store.last_crawl_started_at)}
</span>
) : (
'-'
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded text-xs ${
store.inventory_changes_24h > 0
? 'bg-blue-600 text-white'
: 'bg-gray-700 text-gray-400'
}`}
>
{store.inventory_changes_24h} inv
</span>
<span
className={`px-2 py-0.5 rounded text-xs ${
store.price_changes_24h > 0
? 'bg-yellow-600 text-white'
: 'bg-gray-700 text-gray-400'
}`}
>
{store.price_changes_24h} price
</span>
</div>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => handleRemove(store.id)}
disabled={updating === store.id}
className="p-1 text-red-400 hover:text-red-300 disabled:opacity-50"
title="Remove from high-frequency"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Info Box */}
<div className="mt-6 p-4 bg-gray-800 border border-gray-700 rounded-lg">
<h3 className="text-sm font-medium text-gray-300 mb-2">About High-Frequency Crawling</h3>
<p className="text-sm text-gray-400">
High-frequency crawling allows you to track inventory changes in near real-time for select stores.
Stores on 15-minute intervals will be crawled 96 times per day, enabling detection of:
</p>
<ul className="mt-2 text-sm text-gray-400 list-disc list-inside space-y-1">
<li>Out-of-stock events</li>
<li>Price changes ({'>'}5% threshold)</li>
<li>Brand drops and additions</li>
<li>Stock level changes for velocity calculations</li>
</ul>
</div>
</div>
</Layout>
);
}
export default HighFrequencyManager;

View File

@@ -1,392 +0,0 @@
/**
* Inventory Snapshots Dashboard
*
* View inventory snapshots captured from high-frequency crawls.
* Part of Real-Time Inventory Tracking feature.
*/
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api, InventorySnapshot } from '../lib/api';
import {
Database,
RefreshCw,
AlertCircle,
Search,
Store,
Package,
Clock,
TrendingDown,
Filter,
} from 'lucide-react';
interface SnapshotStats {
total_snapshots: string;
stores_tracked: string;
products_tracked: string;
oldest_snapshot: string;
newest_snapshot: string;
snapshots_24h: string;
snapshots_1h: string;
}
export function InventorySnapshotsDashboard() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [snapshots, setSnapshots] = useState<InventorySnapshot[]>([]);
const [stats, setStats] = useState<SnapshotStats | null>(null);
const [loading, setLoading] = useState(true);
const [dispensaryId, setDispensaryId] = useState<string>('');
const [productId, setProductId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const LIMIT = 50;
// Load data
useEffect(() => {
loadData();
loadStats();
}, [page]);
const loadData = async () => {
try {
setLoading(true);
setError(null);
const params: any = {
limit: LIMIT,
offset: page * LIMIT,
};
if (dispensaryId) {
params.dispensary_id = parseInt(dispensaryId);
}
if (productId) {
params.product_id = productId;
}
const data = await api.getInventorySnapshots(params);
setSnapshots(data.snapshots || []);
setHasMore((data.snapshots || []).length === LIMIT);
} catch (err: any) {
console.error('Failed to load snapshots:', err);
setError(err.message || 'Failed to load snapshots');
} finally {
setLoading(false);
}
};
const loadStats = async () => {
try {
const response = await api.get<{ success: boolean; stats: SnapshotStats }>(
'/api/tasks/inventory-snapshots/stats'
);
setStats(response.data.stats);
} catch (err) {
console.error('Failed to load stats:', err);
}
};
const handleSearch = () => {
setPage(0);
loadData();
};
// Format timestamp
const formatTime = (ts: string) => {
const date = new Date(ts);
return date.toLocaleString();
};
const formatRelativeTime = (ts: string) => {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
};
// Get stock status color
const getStockColor = (qty: number | null, isBelowThreshold: boolean) => {
if (qty === null) return 'text-gray-400';
if (qty === 0 || isBelowThreshold) return 'text-red-400';
if (qty < 10) return 'text-yellow-400';
return 'text-green-400';
};
// Get platform badge color
const getPlatformColor = (platform: string) => {
switch (platform) {
case 'dutchie':
return 'bg-green-600';
case 'jane':
return 'bg-blue-600';
case 'treez':
return 'bg-purple-600';
default:
return 'bg-gray-600';
}
};
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Database className="h-6 w-6 text-purple-500" />
Inventory Snapshots
</h1>
<p className="text-gray-400 mt-1">
View inventory snapshots captured from high-frequency crawls
</p>
</div>
<button
onClick={() => {
loadData();
loadStats();
}}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-4 bg-red-900/50 border border-red-700 rounded-lg flex items-center gap-2 text-red-200">
<AlertCircle className="h-5 w-5" />
{error}
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Database className="h-4 w-4" />
Total Snapshots
</div>
<div className="text-2xl font-bold text-white mt-1">
{parseInt(stats.total_snapshots).toLocaleString()}
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Store className="h-4 w-4" />
Stores Tracked
</div>
<div className="text-2xl font-bold text-white mt-1">{stats.stores_tracked}</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Package className="h-4 w-4" />
Products Tracked
</div>
<div className="text-2xl font-bold text-white mt-1">
{parseInt(stats.products_tracked).toLocaleString()}
</div>
</div>
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Clock className="h-4 w-4" />
Last Hour
</div>
<div className="text-2xl font-bold text-white mt-1">
{parseInt(stats.snapshots_1h).toLocaleString()}
</div>
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<label className="block text-sm text-gray-400 mb-1">Dispensary ID</label>
<input
type="text"
value={dispensaryId}
onChange={(e) => setDispensaryId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Filter by dispensary ID..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex-1 min-w-[200px]">
<label className="block text-sm text-gray-400 mb-1">Product ID</label>
<input
type="text"
value={productId}
onChange={(e) => setProductId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Filter by product ID..."
className="w-full px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex items-end">
<button
onClick={handleSearch}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors"
>
<Filter className="h-4 w-4" />
Apply
</button>
</div>
</div>
{/* Snapshots Table */}
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-900">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Time</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Platform</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Store</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Product</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Brand</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Category</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Qty</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Price</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{loading ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-2 border-blue-500 border-t-transparent mb-2"></div>
<p>Loading snapshots...</p>
</td>
</tr>
) : snapshots.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-gray-400">
No inventory snapshots found
</td>
</tr>
) : (
snapshots.map((snapshot) => (
<tr key={snapshot.id} className="hover:bg-gray-700/50">
<td className="px-4 py-3 text-sm text-gray-300">
<span title={formatTime(snapshot.captured_at)}>
{formatRelativeTime(snapshot.captured_at)}
</span>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded text-xs text-white ${getPlatformColor(
snapshot.platform
)}`}
>
{snapshot.platform}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(`/dispensaries/${snapshot.dispensary_id}`)}
className="text-blue-400 hover:text-blue-300 text-sm"
>
#{snapshot.dispensary_id}
</button>
</td>
<td className="px-4 py-3 text-sm text-white max-w-[200px] truncate">
{snapshot.product_name || snapshot.product_id}
</td>
<td className="px-4 py-3 text-sm text-gray-300">
{snapshot.brand_name || '-'}
</td>
<td className="px-4 py-3 text-sm text-gray-300">
{snapshot.category || '-'}
</td>
<td
className={`px-4 py-3 text-sm text-right font-medium ${getStockColor(
snapshot.quantity_available,
snapshot.is_below_threshold
)}`}
>
{snapshot.quantity_available ?? '-'}
{snapshot.is_below_threshold && (
<TrendingDown className="h-3 w-3 inline ml-1" />
)}
</td>
<td className="px-4 py-3 text-sm text-right text-gray-300">
{snapshot.price_rec ? `$${snapshot.price_rec.toFixed(2)}` : '-'}
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded text-xs ${
snapshot.status === 'Active' || snapshot.status === 'ACTIVE'
? 'bg-green-900/50 text-green-400'
: 'bg-gray-700 text-gray-400'
}`}
>
{snapshot.status || 'Unknown'}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
<div className="mt-4 flex justify-between items-center">
<div className="text-sm text-gray-400">
Showing {snapshots.length} snapshots (page {page + 1})
</div>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
{/* Info Box */}
<div className="mt-6 p-4 bg-gray-800 border border-gray-700 rounded-lg">
<h3 className="text-sm font-medium text-gray-300 mb-2">About Inventory Snapshots</h3>
<p className="text-sm text-gray-400">
Inventory snapshots capture the state of products during each crawl. They include:
</p>
<ul className="mt-2 text-sm text-gray-400 list-disc list-inside space-y-1">
<li>Quantity available (for delta/velocity calculations)</li>
<li>Price (recreational and medical)</li>
<li>Stock status and low-stock indicators</li>
<li>Brand and category information</li>
</ul>
<p className="mt-2 text-sm text-gray-400">
Data is normalized across all platforms (Dutchie, Jane, Treez) into a common format.
</p>
</div>
</div>
</Layout>
);
}
export default InventorySnapshotsDashboard;

View File

@@ -347,17 +347,10 @@ export function PayloadsDashboard() {
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Store className="w-4 h-4 text-gray-400 flex-shrink-0" /> <Store className="w-4 h-4 text-gray-400" />
<div className="min-w-0"> <span className="text-sm font-medium truncate max-w-[200px]">
<div className="text-sm font-medium truncate max-w-[200px]">
{payload.dispensary_name || `Store #${payload.dispensaryId}`} {payload.dispensary_name || `Store #${payload.dispensaryId}`}
</div> </span>
{(payload.city || payload.state) && (
<div className="text-xs text-gray-500 truncate">
{payload.city}{payload.city && payload.state ? ', ' : ''}{payload.state}
</div>
)}
</div>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">

View File

@@ -1,435 +0,0 @@
/**
* Visibility Events Dashboard
*
* View and manage product visibility events (OOS, price changes, brand drops, etc.)
* Part of Real-Time Inventory Tracking feature.
*/
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Layout } from '../components/Layout';
import { api, VisibilityEvent } from '../lib/api';
import { EventTypeBadge, getAllEventTypes, formatPriceChange, EventType } from '../components/EventTypeBadge';
import {
Bell,
RefreshCw,
AlertCircle,
CheckCircle,
Search,
Filter,
Check,
Clock,
Store,
Package,
DollarSign,
Tag,
} from 'lucide-react';
interface EventStats {
total_events: string;
oos_events: string;
back_in_stock_events: string;
brand_dropped_events: string;
brand_added_events: string;
price_change_events: string;
events_24h: string;
acknowledged_events: string;
notified_events: string;
}
export function VisibilityEventsDashboard() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [events, setEvents] = useState<VisibilityEvent[]>([]);
const [stats, setStats] = useState<EventStats | null>(null);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedType, setSelectedType] = useState<EventType | ''>('');
const [selectedEvents, setSelectedEvents] = useState<Set<number>>(new Set());
const [acknowledging, setAcknowledging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [hasMore, setHasMore] = useState(true);
const LIMIT = 50;
// Load data
useEffect(() => {
loadData();
loadStats();
}, [selectedType, page]);
const loadData = async () => {
try {
setLoading(true);
setError(null);
const params: any = {
limit: LIMIT,
offset: page * LIMIT,
};
if (selectedType) {
params.event_type = selectedType;
}
if (searchTerm) {
params.brand = searchTerm;
}
const data = await api.getVisibilityEvents(params);
setEvents(data.events || []);
setHasMore((data.events || []).length === LIMIT);
} catch (err: any) {
console.error('Failed to load visibility events:', err);
setError(err.message || 'Failed to load events');
} finally {
setLoading(false);
}
};
const loadStats = async () => {
try {
const response = await api.get<{ success: boolean; stats: EventStats }>(
'/api/tasks/visibility-events/stats'
);
setStats(response.data.stats);
} catch (err) {
console.error('Failed to load stats:', err);
}
};
const handleAcknowledge = async (eventId: number) => {
try {
setAcknowledging(true);
await api.acknowledgeVisibilityEvent(eventId);
setSuccess('Event acknowledged');
await loadData();
await loadStats();
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Failed to acknowledge event');
} finally {
setAcknowledging(false);
}
};
const handleBulkAcknowledge = async () => {
if (selectedEvents.size === 0) return;
if (!confirm(`Acknowledge ${selectedEvents.size} events?`)) return;
try {
setAcknowledging(true);
await api.post('/api/tasks/visibility-events/acknowledge-bulk', {
event_ids: Array.from(selectedEvents),
});
setSuccess(`${selectedEvents.size} events acknowledged`);
setSelectedEvents(new Set());
await loadData();
await loadStats();
setTimeout(() => setSuccess(null), 3000);
} catch (err: any) {
setError(err.message || 'Failed to acknowledge events');
} finally {
setAcknowledging(false);
}
};
const toggleSelectAll = () => {
if (selectedEvents.size === events.length) {
setSelectedEvents(new Set());
} else {
setSelectedEvents(new Set(events.map((e) => e.id)));
}
};
const toggleSelect = (eventId: number) => {
const newSelected = new Set(selectedEvents);
if (newSelected.has(eventId)) {
newSelected.delete(eventId);
} else {
newSelected.add(eventId);
}
setSelectedEvents(newSelected);
};
// Format timestamp
const formatTime = (ts: string) => {
const date = new Date(ts);
return date.toLocaleString();
};
const formatRelativeTime = (ts: string) => {
const date = new Date(ts);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
};
const eventTypes = getAllEventTypes();
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-2">
<Bell className="h-6 w-6 text-yellow-500" />
Visibility Events
</h1>
<p className="text-gray-400 mt-1">
Track product out-of-stock, price changes, and brand visibility events
</p>
</div>
<div className="flex items-center gap-2">
{selectedEvents.size > 0 && (
<button
onClick={handleBulkAcknowledge}
disabled={acknowledging}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded-lg transition-colors disabled:opacity-50"
>
<Check className="h-4 w-4" />
Acknowledge ({selectedEvents.size})
</button>
)}
<button
onClick={() => {
loadData();
loadStats();
}}
className="flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition-colors"
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
</div>
{/* Alerts */}
{error && (
<div className="mb-4 p-4 bg-red-900/50 border border-red-700 rounded-lg flex items-center gap-2 text-red-200">
<AlertCircle className="h-5 w-5" />
{error}
</div>
)}
{success && (
<div className="mb-4 p-4 bg-green-900/50 border border-green-700 rounded-lg flex items-center gap-2 text-green-200">
<CheckCircle className="h-5 w-5" />
{success}
</div>
)}
{/* Stats Cards */}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Clock className="h-4 w-4" />
Last 24h
</div>
<div className="text-2xl font-bold text-white mt-1">{stats.events_24h}</div>
</div>
<div className="bg-red-900/30 rounded-lg p-4 border border-red-700">
<div className="flex items-center gap-2 text-red-400 text-sm">
<Package className="h-4 w-4" />
OOS Events
</div>
<div className="text-2xl font-bold text-red-400 mt-1">{stats.oos_events}</div>
</div>
<div className="bg-green-900/30 rounded-lg p-4 border border-green-700">
<div className="flex items-center gap-2 text-green-400 text-sm">
<Package className="h-4 w-4" />
Back in Stock
</div>
<div className="text-2xl font-bold text-green-400 mt-1">{stats.back_in_stock_events}</div>
</div>
<div className="bg-yellow-900/30 rounded-lg p-4 border border-yellow-700">
<div className="flex items-center gap-2 text-yellow-400 text-sm">
<DollarSign className="h-4 w-4" />
Price Changes
</div>
<div className="text-2xl font-bold text-yellow-400 mt-1">{stats.price_change_events}</div>
</div>
<div className="bg-orange-900/30 rounded-lg p-4 border border-orange-700">
<div className="flex items-center gap-2 text-orange-400 text-sm">
<Tag className="h-4 w-4" />
Brand Drops
</div>
<div className="text-2xl font-bold text-orange-400 mt-1">{stats.brand_dropped_events}</div>
</div>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400" />
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadData()}
placeholder="Search by brand..."
className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<select
value={selectedType}
onChange={(e) => {
setSelectedType(e.target.value as EventType | '');
setPage(0);
}}
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white focus:ring-2 focus:ring-blue-500"
>
<option value="">All Event Types</option>
{eventTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</div>
{/* Events Table */}
<div className="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
<table className="w-full">
<thead className="bg-gray-900">
<tr>
<th className="px-4 py-3 text-left">
<input
type="checkbox"
checked={selectedEvents.size === events.length && events.length > 0}
onChange={toggleSelectAll}
className="rounded bg-gray-700 border-gray-600 text-blue-500"
/>
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Type</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Time</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Store</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Product/Brand</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Details</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Status</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-400">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
<div className="inline-block animate-spin rounded-full h-6 w-6 border-2 border-blue-500 border-t-transparent mb-2"></div>
<p>Loading events...</p>
</td>
</tr>
) : events.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
No visibility events found
</td>
</tr>
) : (
events.map((event) => (
<tr key={event.id} className="hover:bg-gray-700/50">
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedEvents.has(event.id)}
onChange={() => toggleSelect(event.id)}
className="rounded bg-gray-700 border-gray-600 text-blue-500"
/>
</td>
<td className="px-4 py-3">
<EventTypeBadge type={event.event_type} size="sm" />
</td>
<td className="px-4 py-3 text-sm text-gray-300">
<span title={formatTime(event.detected_at)}>
{formatRelativeTime(event.detected_at)}
</span>
</td>
<td className="px-4 py-3">
<button
onClick={() => navigate(`/dispensaries/${event.dispensary_id}`)}
className="text-blue-400 hover:text-blue-300 text-sm"
>
{event.dispensary_name || `Store #${event.dispensary_id}`}
</button>
</td>
<td className="px-4 py-3">
<div className="text-sm text-white">{event.product_name || event.brand_name || '-'}</div>
{event.product_name && event.brand_name && (
<div className="text-xs text-gray-400">{event.brand_name}</div>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-300">
{event.event_type === 'price_change'
? formatPriceChange(event.previous_price, event.new_price, event.price_change_pct)
: '-'}
</td>
<td className="px-4 py-3">
{event.acknowledged_at ? (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-900/50 text-green-400 rounded text-xs">
<Check className="h-3 w-3" />
Acknowledged
</span>
) : (
<span className="px-2 py-1 bg-gray-700 text-gray-400 rounded text-xs">
Pending
</span>
)}
</td>
<td className="px-4 py-3 text-right">
{!event.acknowledged_at && (
<button
onClick={() => handleAcknowledge(event.id)}
disabled={acknowledging}
className="px-2 py-1 bg-green-600 hover:bg-green-500 text-white rounded text-xs disabled:opacity-50"
>
Acknowledge
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="mt-4 flex justify-between items-center">
<div className="text-sm text-gray-400">
Showing {events.length} events (page {page + 1})
</div>
<div className="flex gap-2">
<button
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</div>
</Layout>
);
}
export default VisibilityEventsDashboard;

View File

@@ -383,10 +383,9 @@ function PreflightSummary({ worker, poolOpen = true }: { worker: Worker; poolOpe
const fingerprint = worker.fingerprint_data; const fingerprint = worker.fingerprint_data;
const httpError = worker.preflight_http_error; const httpError = worker.preflight_http_error;
const httpMs = worker.preflight_http_ms; const httpMs = worker.preflight_http_ms;
// Show DETECTED proxy location (from fingerprint), not assigned state // Geo from current_city/state columns, or fallback to fingerprint detected location
// This lets us verify the proxy is geo-targeted correctly const geoState = worker.current_state || fingerprint?.detectedLocation?.region;
const geoState = fingerprint?.detectedLocation?.region || worker.current_state; const geoCity = worker.current_city || fingerprint?.detectedLocation?.city;
const geoCity = fingerprint?.detectedLocation?.city || worker.current_city;
// Worker is ONLY qualified if http preflight passed AND has geo assigned // Worker is ONLY qualified if http preflight passed AND has geo assigned
const hasGeo = Boolean(geoState); const hasGeo = Boolean(geoState);
const isQualified = (worker.is_qualified || httpStatus === 'passed') && hasGeo; const isQualified = (worker.is_qualified || httpStatus === 'passed') && hasGeo;
@@ -703,9 +702,8 @@ function WorkerSlot({
const httpIp = worker?.http_ip; const httpIp = worker?.http_ip;
const fingerprint = worker?.fingerprint_data; const fingerprint = worker?.fingerprint_data;
// Show DETECTED proxy location (from fingerprint), not assigned state const geoState = worker?.current_state || (fingerprint as any)?.detectedLocation?.region;
const geoState = (fingerprint as any)?.detectedLocation?.region || worker?.current_state; const geoCity = worker?.current_city || (fingerprint as any)?.detectedLocation?.city;
const geoCity = (fingerprint as any)?.detectedLocation?.city || worker?.current_city;
const isQualified = worker?.is_qualified; const isQualified = worker?.is_qualified;
// Build fingerprint tooltip // Build fingerprint tooltip
@@ -805,7 +803,7 @@ function PodVisualization({
// Get the single worker for this pod (1 worker_registry entry per K8s pod) // Get the single worker for this pod (1 worker_registry entry per K8s pod)
const worker = workers[0]; const worker = workers[0];
const activeTasks = worker?.active_tasks ?? []; const activeTasks = worker?.active_tasks ?? [];
const maxSlots = worker?.max_concurrent_tasks ?? 5; const maxSlots = worker?.max_concurrent_tasks ?? 3;
const activeCount = activeTasks.length; const activeCount = activeTasks.length;
const isBackingOff = worker?.metadata?.is_backing_off; const isBackingOff = worker?.metadata?.is_backing_off;
const isDecommissioning = worker?.decommission_requested; const isDecommissioning = worker?.decommission_requested;

View File

@@ -1,84 +0,0 @@
# Using the Docker Registry Cache
To avoid Docker Hub rate limits, use our registry at `registry.spdy.io` (HTTPS) or `10.100.9.70:5000` (HTTP internal).
## For Woodpecker CI (Kaniko builds)
In your `.woodpecker.yml`, use these Kaniko flags:
```yaml
docker-build:
image: gcr.io/kaniko-project/executor:debug
commands:
- /kaniko/executor
--context=/woodpecker/src/...
--dockerfile=Dockerfile
--destination=10.100.9.70:5000/your-image:tag
--registry-mirror=10.100.9.70:5000
--insecure-registry=10.100.9.70:5000
--cache=true
--cache-repo=10.100.9.70:5000/your-image/cache
--cache-ttl=168h
```
**Key points:**
- `--registry-mirror=10.100.9.70:5000` - Pulls base images from local cache
- `--insecure-registry=10.100.9.70:5000` - Allows HTTP (not HTTPS)
- `--cache=true` + `--cache-repo=...` - Caches build layers locally
## Available Base Images
The local registry has these cached:
| Image | Tags |
|-------|------|
| `node` | `20-slim`, `22-slim`, `22-alpine`, `20-alpine` |
| `alpine` | `latest` |
| `nginx` | `alpine` |
| `bitnami/kubectl` | `latest` |
| `gcr.io/kaniko-project/executor` | `debug` |
Need a different image? Add it to the cache using crane:
```bash
kubectl run cache-image --rm -it --restart=Never \
--image=gcr.io/go-containerregistry/crane:latest \
-- copy docker.io/library/IMAGE:TAG 10.100.9.70:5000/library/IMAGE:TAG --insecure
```
## Which Registry URL to Use
| Context | URL | Why |
|---------|-----|-----|
| Kaniko builds (CI) | `10.100.9.70:5000` | Internal HTTP, faster |
| kubectl set image | `registry.spdy.io` | HTTPS, k8s nodes can pull |
| Checking images | Either works | Same backend |
## DO NOT USE
- ~~`--registry-mirror=mirror.gcr.io`~~ - Rate limited by Docker Hub
- ~~Direct pulls from `docker.io`~~ - Rate limited (100 pulls/6hr anonymous)
- ~~`10.100.9.70:5000` in kubectl commands~~ - k8s nodes require HTTPS
## Checking Cached Images
List all cached images:
```bash
curl -s http://10.100.9.70:5000/v2/_catalog | jq
```
List tags for a specific image:
```bash
curl -s http://10.100.9.70:5000/v2/library/node/tags/list | jq
```
## Troubleshooting
### "no such host" or DNS errors
The CI runner can't reach the registry mirror. Make sure you're using `10.100.9.70:5000`, not `mirror.gcr.io`.
### "manifest unknown"
The image/tag isn't cached. Add it using the crane command above.
### HTTP vs HTTPS errors
Always use `--insecure-registry=10.100.9.70:5000` - the local registry uses HTTP.

View File

@@ -1,55 +0,0 @@
# Daily job to sync base images from Docker Hub to local registry
# Runs at 3 AM daily to refresh the cache before rate limits reset
apiVersion: batch/v1
kind: CronJob
metadata:
name: registry-sync
namespace: woodpecker
spec:
schedule: "0 3 * * *" # 3 AM daily
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: sync
image: gcr.io/go-containerregistry/crane:latest
command:
- /bin/sh
- -c
- |
set -e
echo "=== Registry Sync: $(date) ==="
REGISTRY="registry.spdy.io"
# Base images to cache (source of truth for all K8s deployments)
# Add new images here - all deployments should use registry.spdy.io/library/*
IMAGES="
library/busybox:latest
library/node:20-slim
library/node:22-slim
library/node:22
library/node:22-alpine
library/node:20-alpine
library/alpine:latest
library/nginx:alpine
bitnami/kubectl:latest
"
for img in $IMAGES; do
echo "Syncing docker.io/$img -> $REGISTRY/$img"
crane copy "docker.io/$img" "$REGISTRY/$img" || echo "WARN: Failed $img"
done
echo "=== Sync complete ==="
resources:
limits:
memory: "256Mi"
cpu: "200m"
requests:
memory: "128Mi"
cpu: "100m"

View File

@@ -33,7 +33,7 @@ spec:
args: ["dist/tasks/task-worker.js"] args: ["dist/tasks/task-worker.js"]
envFrom: envFrom:
- configMapRef: - configMapRef:
name: cannaiq-config name: scraper-config
- secretRef: - secretRef:
name: scraper-secrets name: scraper-secrets
env: env:
@@ -51,17 +51,11 @@ spec:
# 3 browsers × ~400MB = ~1.3GB (safe for 2GB pod limit) # 3 browsers × ~400MB = ~1.3GB (safe for 2GB pod limit)
- name: MAX_CONCURRENT_TASKS - name: MAX_CONCURRENT_TASKS
value: "3" value: "3"
# Session Pool: CORRECT FLOW - claim tasks first, then get IP # Task Pool System (geo-based pools)
# 1. Worker claims tasks (no IP yet) # Correct flow: check pools → claim pool → get proxy → preflight → pull tasks
# 2. Get city/state from task
# 3. Get IP matching that city/state
# 4. Execute tasks with that IP
# 5. Retire IP (8hr cooldown)
- name: USE_SESSION_POOL
value: "true"
# Disable legacy modes (wrong flow - get IP before tasks)
- name: USE_TASK_POOLS - name: USE_TASK_POOLS
value: "false" value: "true"
# Disable legacy identity pool
- name: USE_IDENTITY_POOL - name: USE_IDENTITY_POOL
value: "false" value: "false"
resources: resources:

View File

@@ -1,10 +1,20 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: scraper-images-pvc
namespace: cannaiq
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: scraper name: scraper
namespace: cannaiq namespace: cannaiq
labels:
app: scraper
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -15,22 +25,27 @@ spec:
labels: labels:
app: scraper app: scraper
spec: spec:
serviceAccountName: scraper-sa
imagePullSecrets: imagePullSecrets:
- name: gitea-registry - name: regcred
containers: containers:
- name: scraper - name: scraper
image: registry.spdy.io/cannaiq/backend:latest image: git.spdy.io/creationshop/cannaiq:latest
imagePullPolicy: Always
ports: ports:
- containerPort: 3000 - containerPort: 3010
envFrom: envFrom:
- configMapRef: - configMapRef:
name: cannaiq-config name: scraper-config
- secretRef:
name: scraper-secrets
volumeMounts:
- name: images-storage
mountPath: /app/public/images
# Liveness probe: restarts pod if it becomes unresponsive # Liveness probe: restarts pod if it becomes unresponsive
livenessProbe: livenessProbe:
httpGet: httpGet:
path: /health path: /health
port: 3000 port: 3010
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
@@ -39,7 +54,7 @@ spec:
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /health path: /health
port: 3000 port: 3010
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 5 timeoutSeconds: 5
@@ -49,5 +64,9 @@ spec:
memory: "512Mi" memory: "512Mi"
cpu: "250m" cpu: "250m"
limits: limits:
memory: "2Gi" memory: "1Gi"
cpu: "1000m" cpu: "1000m"
volumes:
- name: images-storage
persistentVolumeClaim:
claimName: scraper-images-pvc

View File

@@ -1 +1 @@
2.3.0 1.7.0

View File

@@ -1,813 +0,0 @@
/**
* CannaIQ Modular Components CSS
*
* Styles for the modular component library.
* Each component is independently styled and composable.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
/* ==========================================================================
CSS Variables (Design Tokens)
========================================================================== */
:root {
/* Strain Colors */
--cannaiq-sativa: #22c55e;
--cannaiq-indica: #8b5cf6;
--cannaiq-hybrid: #f97316;
/* UI Colors */
--cannaiq-discount: #ef4444;
--cannaiq-discount-bg: #fef2f2;
--cannaiq-sale: #dc2626;
--cannaiq-stock-in: #16a34a;
--cannaiq-stock-out: #9ca3af;
--cannaiq-price-original: #9ca3af;
--cannaiq-price-sale: #dc2626;
/* Neutrals */
--cannaiq-text-primary: #1f2937;
--cannaiq-text-secondary: #6b7280;
--cannaiq-text-muted: #9ca3af;
--cannaiq-border: #e5e7eb;
--cannaiq-bg-light: #f9fafb;
/* Spacing */
--cannaiq-space-xs: 0.25rem;
--cannaiq-space-sm: 0.5rem;
--cannaiq-space-md: 0.75rem;
--cannaiq-space-lg: 1rem;
--cannaiq-space-xl: 1.5rem;
/* Border Radius */
--cannaiq-radius-sm: 0.25rem;
--cannaiq-radius-md: 0.375rem;
--cannaiq-radius-lg: 0.5rem;
--cannaiq-radius-xl: 0.75rem;
--cannaiq-radius-full: 9999px;
/* Shadows */
--cannaiq-shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--cannaiq-shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
--cannaiq-shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
}
/* ==========================================================================
Discount Ribbon
========================================================================== */
.cannaiq-discount-ribbon {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.025em;
white-space: nowrap;
}
/* Ribbon Style - Corner positioned */
.cannaiq-discount-ribbon--ribbon {
position: absolute;
top: 0;
left: 0;
background: var(--cannaiq-discount);
color: white;
padding: var(--cannaiq-space-xs) var(--cannaiq-space-md);
font-size: 0.75rem;
border-bottom-right-radius: var(--cannaiq-radius-md);
z-index: 10;
}
/* Pill Style */
.cannaiq-discount-ribbon--pill {
background: var(--cannaiq-discount);
color: white;
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
font-size: 0.75rem;
border-radius: var(--cannaiq-radius-full);
}
/* Text Style */
.cannaiq-discount-ribbon--text {
color: var(--cannaiq-discount);
font-size: 0.875rem;
}
/* Sizes */
.cannaiq-discount-ribbon--small {
font-size: 0.625rem;
padding: 2px var(--cannaiq-space-xs);
}
.cannaiq-discount-ribbon--large {
font-size: 0.875rem;
padding: var(--cannaiq-space-sm) var(--cannaiq-space-lg);
}
/* ==========================================================================
Strain Badge
========================================================================== */
.cannaiq-strain-badge {
display: inline-flex;
align-items: center;
gap: var(--cannaiq-space-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: var(--cannaiq-radius-full);
}
/* Pill Style */
.cannaiq-strain-badge--pill {
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
font-size: 0.625rem;
}
/* Text Style */
.cannaiq-strain-badge--text {
padding: 0;
font-size: 0.75rem;
}
/* Strain Type Colors */
.cannaiq-strain-badge--sativa {
background: var(--cannaiq-sativa);
color: white;
}
.cannaiq-strain-badge--sativa.cannaiq-strain-badge--text {
background: transparent;
color: var(--cannaiq-sativa);
}
.cannaiq-strain-badge--indica {
background: var(--cannaiq-indica);
color: white;
}
.cannaiq-strain-badge--indica.cannaiq-strain-badge--text {
background: transparent;
color: var(--cannaiq-indica);
}
.cannaiq-strain-badge--hybrid {
background: var(--cannaiq-hybrid);
color: white;
}
.cannaiq-strain-badge--hybrid.cannaiq-strain-badge--text {
background: transparent;
color: var(--cannaiq-hybrid);
}
/* Sizes */
.cannaiq-strain-badge--small {
font-size: 0.5rem;
padding: 2px var(--cannaiq-space-xs);
}
.cannaiq-strain-badge--large {
font-size: 0.75rem;
padding: var(--cannaiq-space-sm) var(--cannaiq-space-md);
}
/* ==========================================================================
THC/CBD Badge
========================================================================== */
.cannaiq-potency-badge {
display: inline-flex;
align-items: center;
gap: var(--cannaiq-space-xs);
font-weight: 600;
}
/* Badge Style */
.cannaiq-potency-badge--badge {
background: var(--cannaiq-bg-light);
border: 1px solid var(--cannaiq-border);
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
border-radius: var(--cannaiq-radius-md);
font-size: 0.75rem;
}
/* Pill Style */
.cannaiq-potency-badge--pill {
background: var(--cannaiq-text-primary);
color: white;
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
border-radius: var(--cannaiq-radius-full);
font-size: 0.75rem;
}
/* Text Style */
.cannaiq-potency-badge--text {
color: var(--cannaiq-text-secondary);
font-size: 0.875rem;
}
.cannaiq-potency-badge__label {
color: var(--cannaiq-text-muted);
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cannaiq-potency-badge__value {
font-weight: 700;
}
/* ==========================================================================
THC/CBD Meter (Visual Progress Bar)
========================================================================== */
.cannaiq-potency-meter {
display: flex;
flex-direction: column;
gap: var(--cannaiq-space-xs);
}
.cannaiq-potency-meter__header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
}
.cannaiq-potency-meter__label {
font-weight: 600;
color: var(--cannaiq-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cannaiq-potency-meter__value {
font-weight: 700;
color: var(--cannaiq-text-primary);
}
.cannaiq-potency-meter__bar {
height: 6px;
background: var(--cannaiq-border);
border-radius: var(--cannaiq-radius-full);
overflow: hidden;
}
.cannaiq-potency-meter__fill {
height: 100%;
border-radius: var(--cannaiq-radius-full);
transition: width 0.3s ease;
}
.cannaiq-potency-meter--thc .cannaiq-potency-meter__fill {
background: linear-gradient(90deg, #22c55e 0%, #16a34a 100%);
}
.cannaiq-potency-meter--cbd .cannaiq-potency-meter__fill {
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
}
/* ==========================================================================
Effects Display
========================================================================== */
.cannaiq-effects-container {
display: flex;
flex-wrap: wrap;
gap: var(--cannaiq-space-sm);
}
.cannaiq-effect-chip {
display: inline-flex;
align-items: center;
gap: var(--cannaiq-space-xs);
background: color-mix(in srgb, var(--effect-color, #6b7280) 15%, white);
border: 1px solid color-mix(in srgb, var(--effect-color, #6b7280) 30%, white);
border-radius: var(--cannaiq-radius-full);
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
font-size: 0.75rem;
font-weight: 500;
color: var(--cannaiq-text-primary);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.cannaiq-effect-chip:hover {
transform: translateY(-1px);
box-shadow: var(--cannaiq-shadow-sm);
}
.cannaiq-effect-chip svg {
flex-shrink: 0;
}
.cannaiq-effect-chip__label {
white-space: nowrap;
}
/* Effect Chip Sizes */
.cannaiq-effect-chip--small {
padding: 2px var(--cannaiq-space-xs);
font-size: 0.625rem;
gap: 2px;
}
.cannaiq-effect-chip--large {
padding: var(--cannaiq-space-sm) var(--cannaiq-space-md);
font-size: 0.875rem;
}
/* ==========================================================================
Terpene Profile
========================================================================== */
.cannaiq-terpenes {
display: flex;
flex-direction: column;
gap: var(--cannaiq-space-sm);
}
.cannaiq-terpenes--chips {
flex-direction: row;
flex-wrap: wrap;
}
.cannaiq-terpene-chip {
display: inline-flex;
align-items: center;
gap: var(--cannaiq-space-xs);
background: var(--cannaiq-bg-light);
border: 1px solid var(--cannaiq-border);
border-radius: var(--cannaiq-radius-full);
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
font-size: 0.75rem;
}
.cannaiq-terpene-chip__name {
font-weight: 500;
color: var(--cannaiq-text-primary);
}
.cannaiq-terpene-chip__percent {
color: var(--cannaiq-text-secondary);
}
/* Terpene List Style */
.cannaiq-terpenes--list .cannaiq-terpene-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--cannaiq-space-xs) 0;
border-bottom: 1px solid var(--cannaiq-border);
}
.cannaiq-terpenes--list .cannaiq-terpene-item:last-child {
border-bottom: none;
}
/* ==========================================================================
Price Block
========================================================================== */
.cannaiq-price-block {
display: flex;
align-items: baseline;
gap: var(--cannaiq-space-sm);
flex-wrap: wrap;
}
.cannaiq-price-block--stacked {
flex-direction: column;
align-items: flex-start;
gap: var(--cannaiq-space-xs);
}
.cannaiq-price-block__original {
color: var(--cannaiq-price-original);
text-decoration: line-through;
font-size: 0.875rem;
}
.cannaiq-price-block__sale {
color: var(--cannaiq-price-sale);
font-weight: 700;
font-size: 1.25rem;
}
.cannaiq-price-block__regular {
color: var(--cannaiq-text-primary);
font-weight: 700;
font-size: 1.25rem;
}
.cannaiq-price-block__weight {
color: var(--cannaiq-text-muted);
font-size: 0.875rem;
}
/* Price Sizes */
.cannaiq-price-block--small .cannaiq-price-block__sale,
.cannaiq-price-block--small .cannaiq-price-block__regular {
font-size: 1rem;
}
.cannaiq-price-block--small .cannaiq-price-block__original {
font-size: 0.75rem;
}
.cannaiq-price-block--large .cannaiq-price-block__sale,
.cannaiq-price-block--large .cannaiq-price-block__regular {
font-size: 1.5rem;
}
/* ==========================================================================
Cart Button
========================================================================== */
.cannaiq-cart-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--cannaiq-space-sm);
padding: var(--cannaiq-space-md) var(--cannaiq-space-xl);
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
border-radius: var(--cannaiq-radius-md);
cursor: pointer;
transition: all 0.15s ease;
border: 2px solid transparent;
text-transform: uppercase;
letter-spacing: 0.025em;
}
/* Solid Style */
.cannaiq-cart-button--solid {
background: var(--cannaiq-text-primary);
color: white;
border-color: var(--cannaiq-text-primary);
}
.cannaiq-cart-button--solid:hover {
background: #374151;
border-color: #374151;
transform: translateY(-1px);
box-shadow: var(--cannaiq-shadow-md);
}
/* Outline Style */
.cannaiq-cart-button--outline {
background: transparent;
color: var(--cannaiq-text-primary);
border-color: var(--cannaiq-text-primary);
}
.cannaiq-cart-button--outline:hover {
background: var(--cannaiq-text-primary);
color: white;
}
/* Full Width */
.cannaiq-cart-button--full {
width: 100%;
}
/* Sizes */
.cannaiq-cart-button--small {
padding: var(--cannaiq-space-sm) var(--cannaiq-space-md);
font-size: 0.75rem;
}
.cannaiq-cart-button--large {
padding: var(--cannaiq-space-lg) var(--cannaiq-space-xl);
font-size: 1rem;
}
/* ==========================================================================
Stock Indicator
========================================================================== */
.cannaiq-stock-indicator {
display: inline-flex;
align-items: center;
gap: var(--cannaiq-space-xs);
font-size: 0.75rem;
font-weight: 500;
}
.cannaiq-stock-indicator--in-stock {
color: var(--cannaiq-stock-in);
}
.cannaiq-stock-indicator--out-of-stock {
color: var(--cannaiq-stock-out);
}
/* Badge Style */
.cannaiq-stock-indicator--badge {
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
border-radius: var(--cannaiq-radius-md);
}
.cannaiq-stock-indicator--badge.cannaiq-stock-indicator--in-stock {
background: #dcfce7;
}
.cannaiq-stock-indicator--badge.cannaiq-stock-indicator--out-of-stock {
background: #f3f4f6;
}
/* Dot Indicator */
.cannaiq-stock-indicator__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
/* ==========================================================================
Product Image with Overlays
========================================================================== */
.cannaiq-product-image {
position: relative;
overflow: hidden;
border-radius: var(--cannaiq-radius-lg);
background: var(--cannaiq-bg-light);
}
.cannaiq-product-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.cannaiq-product-image:hover img {
transform: scale(1.05);
}
.cannaiq-product-image__overlay {
position: absolute;
padding: var(--cannaiq-space-sm);
}
.cannaiq-product-image__overlay--top-left {
top: 0;
left: 0;
}
.cannaiq-product-image__overlay--top-right {
top: 0;
right: 0;
}
.cannaiq-product-image__overlay--bottom-left {
bottom: 0;
left: 0;
}
.cannaiq-product-image__overlay--bottom-right {
bottom: 0;
right: 0;
}
/* Badge Stack in Overlays */
.cannaiq-product-image__badges {
display: flex;
gap: var(--cannaiq-space-xs);
flex-wrap: wrap;
}
/* ==========================================================================
Weight Options Selector
========================================================================== */
.cannaiq-weight-options {
display: flex;
gap: var(--cannaiq-space-xs);
flex-wrap: wrap;
}
.cannaiq-weight-option {
padding: var(--cannaiq-space-xs) var(--cannaiq-space-sm);
border: 1px solid var(--cannaiq-border);
border-radius: var(--cannaiq-radius-md);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
background: white;
}
.cannaiq-weight-option:hover {
border-color: var(--cannaiq-text-primary);
}
.cannaiq-weight-option--selected {
background: var(--cannaiq-text-primary);
color: white;
border-color: var(--cannaiq-text-primary);
}
.cannaiq-weight-option__price {
color: var(--cannaiq-text-secondary);
margin-left: var(--cannaiq-space-xs);
}
.cannaiq-weight-option--selected .cannaiq-weight-option__price {
color: rgba(255, 255, 255, 0.8);
}
/* Dropdown Style */
.cannaiq-weight-options--dropdown {
display: block;
}
.cannaiq-weight-options--dropdown select {
width: 100%;
padding: var(--cannaiq-space-sm) var(--cannaiq-space-md);
border: 1px solid var(--cannaiq-border);
border-radius: var(--cannaiq-radius-md);
font-size: 0.875rem;
background: white;
cursor: pointer;
}
/* ==========================================================================
Card Container (for premade templates)
========================================================================== */
.cannaiq-product-card {
display: flex;
flex-direction: column;
background: white;
border-radius: var(--cannaiq-radius-xl);
overflow: hidden;
box-shadow: var(--cannaiq-shadow-sm);
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.cannaiq-product-card:hover {
box-shadow: var(--cannaiq-shadow-lg);
transform: translateY(-2px);
}
.cannaiq-product-card__image {
position: relative;
aspect-ratio: 1;
overflow: hidden;
}
.cannaiq-product-card__body {
padding: var(--cannaiq-space-lg);
display: flex;
flex-direction: column;
gap: var(--cannaiq-space-sm);
flex: 1;
}
.cannaiq-product-card__title {
font-size: 1rem;
font-weight: 700;
color: var(--cannaiq-text-primary);
margin: 0;
line-height: 1.3;
}
.cannaiq-product-card__brand {
font-size: 0.875rem;
color: var(--cannaiq-text-secondary);
margin: 0;
}
.cannaiq-product-card__footer {
margin-top: auto;
padding-top: var(--cannaiq-space-md);
}
/* ==========================================================================
Utility Classes
========================================================================== */
.cannaiq-flex {
display: flex;
}
.cannaiq-flex-wrap {
flex-wrap: wrap;
}
.cannaiq-items-center {
align-items: center;
}
.cannaiq-justify-between {
justify-content: space-between;
}
.cannaiq-gap-xs {
gap: var(--cannaiq-space-xs);
}
.cannaiq-gap-sm {
gap: var(--cannaiq-space-sm);
}
.cannaiq-gap-md {
gap: var(--cannaiq-space-md);
}
.cannaiq-gap-lg {
gap: var(--cannaiq-space-lg);
}
.cannaiq-mt-auto {
margin-top: auto;
}
.cannaiq-text-center {
text-align: center;
}
.cannaiq-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ==========================================================================
Card Containers (v2.1) - Empty containers for custom layouts
========================================================================== */
/* Premium Card Container - Vertical product cards */
.cannaiq-card-premium {
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
/* Promo Banner Container - Horizontal promotional banners */
.cannaiq-card-promo {
display: flex;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.cannaiq-card-promo::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
.cannaiq-card-promo > * {
position: relative;
z-index: 1;
}
/* Horizontal Row Container - Product list rows */
.cannaiq-card-horizontal {
display: flex;
flex-direction: row;
position: relative;
box-sizing: border-box;
}
/* Hover Animations for Card Containers */
.cannaiq-hover-lift:hover {
transform: translateY(-8px);
}
.cannaiq-hover-scale:hover {
transform: scale(1.02);
}
.cannaiq-hover-glow:hover {
box-shadow: 0 0 30px rgba(34, 197, 94, 0.3);
}
.cannaiq-hover-brighten:hover {
filter: brightness(1.1);
}
/* Responsive adjustments for card containers */
@media (max-width: 768px) {
.cannaiq-card-promo {
flex-direction: column;
}
.cannaiq-card-horizontal {
flex-direction: column;
}
}

View File

@@ -1,150 +1,15 @@
/** /**
* CannaIQ Menus - WordPress Plugin JavaScript * CannaIQ Menus - WordPress Plugin JavaScript
* v2.0.0 * v1.5.3
*/ */
(function($) { (function($) {
'use strict'; 'use strict';
/**
* Click Analytics Tracker
*/
var CannaiQAnalytics = {
/**
* Track a click event
* @param {string} eventType - Type of event (add_to_cart, product_view, promo_click, etc)
* @param {object} data - Event data (product_id, store_id, product_name, etc)
*/
track: function(eventType, data) {
if (!window.cannaiqAnalytics || !window.cannaiqAnalytics.enabled) {
return;
}
var payload = {
event_type: eventType,
store_id: data.store_id || window.cannaiqAnalytics.store_id,
product_id: data.product_id || null,
product_name: data.product_name || null,
product_price: data.product_price || null,
category: data.category || null,
url: data.url || window.location.href,
referrer: document.referrer || null,
timestamp: new Date().toISOString()
};
// Send to analytics endpoint
$.ajax({
url: window.cannaiqAnalytics.api_url + '/analytics/click',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
headers: {
'Authorization': 'Bearer ' + window.cannaiqAnalytics.api_token
},
// Fire and forget - don't block user interaction
async: true
});
},
/**
* Initialize click tracking on all CannaiQ elements
*/
init: function() {
var self = this;
// Track ALL outbound clicks to dispensary menus (Dutchie, iHeartJane, Treez, etc.)
// This catches any link leaving the site to a menu provider
$(document).on('click', 'a[href*="dutchie.com"], a[href*="iheartjane.com"], a[href*="jane.com"], a[href*="treez.io"], a[href*="weedmaps.com"], a[href*="leafly.com"], .cannaiq-cart-button, .cannaiq-add-to-cart', function(e) {
var $el = $(this);
var $card = $el.closest('[data-product-id], .cannaiq-product-card, .cannaiq-premium-card, .cannaiq-horizontal-row, .cannaiq-compact-card, .cannaiq-product-item');
// Get product data from element or parent
var productId = $el.data('product-id') || $card.data('product-id') || '';
var productName = $el.data('product-name') || $card.data('product-name') || $card.find('.cannaiq-product-name, .cannaiq-premium-name, .cannaiq-hr-name, .cannaiq-cc-name, [data-product-name]').first().text().trim() || '';
var productPrice = $el.data('product-price') || $card.data('product-price') || '';
var storeId = $el.data('store-id') || $card.data('store-id') || '';
var category = $el.data('category') || $card.data('category') || '';
var onSpecial = $el.data('on-special') || $card.data('on-special') || false;
var destinationUrl = $el.attr('href');
self.track('add_to_cart', {
product_id: productId,
product_name: productName,
product_price: productPrice,
store_id: storeId,
category: category,
on_special: onSpecial,
url: destinationUrl
});
});
// Track product card clicks (view intent)
$(document).on('click', '.cannaiq-product-card, .cannaiq-premium-card, .cannaiq-special-card', function(e) {
// Don't double-track if clicking the cart button
if ($(e.target).closest('.cannaiq-cart-button, .cannaiq-add-to-cart').length) {
return;
}
var $card = $(this);
self.track('product_view', {
product_id: $card.data('product-id'),
product_name: $card.data('product-name') || $card.find('.cannaiq-product-name, .cannaiq-premium-name').first().text().trim(),
product_price: $card.data('product-price'),
store_id: $card.data('store-id'),
category: $card.data('category')
});
});
// Track promo banner clicks
$(document).on('click', '.cannaiq-promo-banner .cannaiq-promo-button, .cannaiq-promo-banner', function(e) {
var $banner = $(this).closest('.cannaiq-promo-banner');
self.track('promo_click', {
store_id: $banner.data('store-id'),
promo_headline: $banner.find('.cannaiq-promo-headline').text().trim(),
url: $(this).attr('href') || $banner.find('a').first().attr('href')
});
});
// Track category clicks
$(document).on('click', '.cannaiq-category-card, .cannaiq-category-item', function(e) {
var $cat = $(this);
self.track('category_click', {
store_id: $cat.data('store-id'),
category: $cat.data('category') || $cat.find('.cannaiq-cat-name, .cannaiq-category-name').first().text().trim(),
url: $cat.attr('href')
});
});
// Generic tracking via data-cannaiq-track attribute
// Usage: <a href="..." data-cannaiq-track="shop_now">Shop Now</a>
// Optional: data-cannaiq-track-category, data-cannaiq-track-label, data-cannaiq-track-value
$(document).on('click', '[data-cannaiq-track]', function(e) {
var $el = $(this);
var trackName = $el.data('cannaiq-track');
// Don't double-track elements that are already handled above
if ($el.is('.cannaiq-cart-button, .cannaiq-add-to-cart, .cannaiq-product-card, .cannaiq-premium-card, .cannaiq-special-card, .cannaiq-category-card, .cannaiq-promo-banner')) {
return;
}
self.track(trackName, {
store_id: $el.data('store-id') || $el.closest('[data-store-id]').data('store-id'),
category: $el.data('cannaiq-track-category') || null,
label: $el.data('cannaiq-track-label') || $el.text().trim().substring(0, 100),
value: $el.data('cannaiq-track-value') || null,
url: $el.attr('href') || null
});
});
}
};
/** /**
* Initialize plugin * Initialize plugin
*/ */
$(document).ready(function() { $(document).ready(function() {
// Initialize analytics tracking
CannaiQAnalytics.init();
// Lazy load images // Lazy load images
if ('IntersectionObserver' in window) { if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => { const imageObserver = new IntersectionObserver((entries, observer) => {
@@ -184,13 +49,10 @@
threshold: 0.1 threshold: 0.1
}); });
document.querySelectorAll('.cannaiq-product-card, .cannaiq-premium-card, .cannaiq-compact-card').forEach(card => { document.querySelectorAll('.cannaiq-product-card').forEach(card => {
cardObserver.observe(card); cardObserver.observe(card);
}); });
} }
}); });
// Expose for external use
window.CannaiQAnalytics = CannaiQAnalytics;
})(jQuery); })(jQuery);

View File

@@ -36,28 +36,17 @@ echo " Output: ${OUTPUT_DIR}/${OUTPUT_FILE}"
# Ensure output directory exists # Ensure output directory exists
mkdir -p "${OUTPUT_DIR}" mkdir -p "${OUTPUT_DIR}"
# Create the zip file with proper WordPress structure # Create the zip file (from the plugin directory)
# WordPress expects: cannaiq-menus/cannaiq-menus.php, cannaiq-menus/assets/, etc. cd "${PLUGIN_DIR}"
cd "${PLUGIN_DIR}/.."
rm -f "${OUTPUT_DIR}/${OUTPUT_FILE}" rm -f "${OUTPUT_DIR}/${OUTPUT_FILE}"
# Create temp directory with correct name # Exclude old/legacy files and build script
rm -rf /tmp/cannaiq-menus zip -r "${OUTPUT_DIR}/${OUTPUT_FILE}" . \
cp -r "${PLUGIN_DIR}" /tmp/cannaiq-menus -x "*.git*" \
-x "build-plugin.sh" \
# Remove excluded files -x "crawlsy-menus.php" \
rm -rf /tmp/cannaiq-menus/.git -x "assets/css/crawlsy-menus.css" \
rm -f /tmp/cannaiq-menus/build-plugin.sh -x "assets/js/crawlsy-menus.js"
rm -f /tmp/cannaiq-menus/crawlsy-menus.php
rm -f /tmp/cannaiq-menus/assets/css/crawlsy-menus.css
rm -f /tmp/cannaiq-menus/assets/js/crawlsy-menus.js
# Create ZIP from temp directory
cd /tmp
zip -r "${OUTPUT_DIR}/${OUTPUT_FILE}" cannaiq-menus
# Cleanup
rm -rf /tmp/cannaiq-menus
# Create/update the "latest" symlink (for local reference) # Create/update the "latest" symlink (for local reference)
cd "${OUTPUT_DIR}" cd "${OUTPUT_DIR}"

View File

@@ -1,10 +1,10 @@
<?php <?php
/** /**
* 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: 2.3.0 * Version: 1.7.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
* Text Domain: cannaiq-menus * Text Domain: cannaiq-menus
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
exit; // Exit if accessed directly exit; // Exit if accessed directly
} }
define('CANNAIQ_MENUS_VERSION', '2.2.0'); define('CANNAIQ_MENUS_VERSION', '1.7.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__));
@@ -44,67 +44,30 @@ class CannaIQ_Menus_Plugin {
} }
/** /**
* Register CannaIQ Elementor Widget Categories * Register CannaIQ Elementor Widget Category
*/ */
public function register_elementor_category($elements_manager) { public function register_elementor_category($elements_manager) {
$elements_manager->add_category( $elements_manager->add_category(
'cannaiq', 'cannaiq',
[ [
'title' => __('CannaiQ', 'cannaiq-menus'), 'title' => __('CannaIQ', 'cannaiq-menus'),
'icon' => 'fa fa-cannabis', 'icon' => 'fa fa-cannabis',
] ]
); );
$elements_manager->add_category(
'cannaiq-templates',
[
'title' => __('CannaiQ Templates', 'cannaiq-menus'),
'icon' => 'fa fa-th-large',
]
);
$elements_manager->add_category(
'cannaiq-cards',
[
'title' => __('CannaiQ Card Containers', 'cannaiq-menus'),
'icon' => 'fa fa-square',
]
);
} }
public function init() { public function init() {
// 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');
// Load helper functions
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'includes/effects-icons.php';
// Load Elementor Dynamic Tags (if Elementor is active) // Load Elementor Dynamic Tags (if Elementor is active)
if (did_action('elementor/loaded')) { if (did_action('elementor/loaded')) {
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/dynamic-tags.php'; require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/dynamic-tags.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/dynamic-tags-extended.php';
} }
// Load Loop Builder integration (for Elementor Loop Grid support) // Register shortcodes - primary CannaIQ shortcodes
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'includes/loop-builder.php';
// 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']);
add_shortcode('cannaiq_specials', [$this, 'specials_shortcode']);
add_shortcode('cannaiq_brands', [$this, 'brands_shortcode']);
add_shortcode('cannaiq_categories', [$this, 'categories_shortcode']);
// Component shortcodes (v2.0)
add_shortcode('cannaiq_discount_badge', [$this, 'discount_badge_shortcode']);
add_shortcode('cannaiq_strain_badge', [$this, 'strain_badge_shortcode']);
add_shortcode('cannaiq_thc', [$this, 'thc_shortcode']);
add_shortcode('cannaiq_cbd', [$this, 'cbd_shortcode']);
add_shortcode('cannaiq_effects', [$this, 'effects_shortcode']);
add_shortcode('cannaiq_price', [$this, 'price_shortcode']);
add_shortcode('cannaiq_cart_button', [$this, 'cart_button_shortcode']);
add_shortcode('cannaiq_stock', [$this, 'stock_shortcode']);
add_shortcode('cannaiq_terpenes', [$this, 'terpenes_shortcode']);
add_shortcode('cannaiq_track', [$this, 'track_shortcode']);
add_shortcode('cannaiq_product_wrapper', [$this, 'product_wrapper_shortcode']);
// DEPRECATED: Legacy shortcode alias for backward compatibility only // DEPRECATED: Legacy shortcode alias for backward compatibility only
add_shortcode('crawlsy_products', [$this, 'products_shortcode']); add_shortcode('crawlsy_products', [$this, 'products_shortcode']);
@@ -115,7 +78,6 @@ class CannaIQ_Menus_Plugin {
* Register Elementor Widgets * Register Elementor Widgets
*/ */
public function register_elementor_widgets($widgets_manager) { public function register_elementor_widgets($widgets_manager) {
// Legacy widgets
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/brand-grid.php';
@@ -123,64 +85,18 @@ class CannaIQ_Menus_Plugin {
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/specials-grid.php'; require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/specials-grid.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-loop.php'; require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-loop.php';
// Modular component widgets (v2.0)
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/discount-ribbon.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/strain-badge.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/thc-meter.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/effects-display.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/price-block.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/cart-button.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/stock-indicator.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/product-image-overlay.php';
// Card templates (v2.0) - DEPRECATED: use card containers instead
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-premium.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-promo-banner.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-horizontal.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-category.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-compact.php';
// Card containers (v2.1) - Empty containers for custom layouts
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-container-premium.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-container-promo.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-container-horizontal.php';
// Register legacy widgets
$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_Brand_Grid_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Category_List_Widget()); $widgets_manager->register(new \CannaIQ_Menus_Category_List_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Specials_Grid_Widget()); $widgets_manager->register(new \CannaIQ_Menus_Specials_Grid_Widget());
$widgets_manager->register(new \CannaIQ_Product_Loop_Widget()); $widgets_manager->register(new \CannaIQ_Product_Loop_Widget());
// Register modular component widgets (v2.0)
$widgets_manager->register(new \CannaIQ_Discount_Ribbon_Widget());
$widgets_manager->register(new \CannaIQ_Strain_Badge_Widget());
$widgets_manager->register(new \CannaIQ_THC_Meter_Widget());
$widgets_manager->register(new \CannaIQ_Effects_Display_Widget());
$widgets_manager->register(new \CannaIQ_Price_Block_Widget());
$widgets_manager->register(new \CannaIQ_Cart_Button_Widget());
$widgets_manager->register(new \CannaIQ_Stock_Indicator_Widget());
$widgets_manager->register(new \CannaIQ_Product_Image_Overlay_Widget());
// Register card templates (v2.0) - DEPRECATED
$widgets_manager->register(new \CannaIQ_Premium_Card_Widget());
$widgets_manager->register(new \CannaIQ_Promo_Banner_Widget());
$widgets_manager->register(new \CannaIQ_Card_Horizontal_Widget());
$widgets_manager->register(new \CannaIQ_Card_Category_Widget());
$widgets_manager->register(new \CannaIQ_Card_Compact_Widget());
// Register card containers (v2.1) - Empty containers for custom layouts
$widgets_manager->register(new \CannaiQ_Card_Container_Premium());
$widgets_manager->register(new \CannaiQ_Card_Container_Promo());
$widgets_manager->register(new \CannaiQ_Card_Container_Horizontal());
} }
/** /**
* Enqueue Scripts and Styles * Enqueue Scripts and Styles
*/ */
public function enqueue_scripts() { public function enqueue_scripts() {
// Base styles
wp_enqueue_style( wp_enqueue_style(
'cannaiq-menus-styles', 'cannaiq-menus-styles',
CANNAIQ_MENUS_PLUGIN_URL . 'assets/css/cannaiq-menus.css', CANNAIQ_MENUS_PLUGIN_URL . 'assets/css/cannaiq-menus.css',
@@ -188,14 +104,6 @@ class CannaIQ_Menus_Plugin {
CANNAIQ_MENUS_VERSION CANNAIQ_MENUS_VERSION
); );
// Component styles (v2.0 modular components)
wp_enqueue_style(
'cannaiq-components-styles',
CANNAIQ_MENUS_PLUGIN_URL . 'assets/css/components.css',
['cannaiq-menus-styles'],
CANNAIQ_MENUS_VERSION
);
wp_enqueue_script( wp_enqueue_script(
'cannaiq-menus-script', 'cannaiq-menus-script',
CANNAIQ_MENUS_PLUGIN_URL . 'assets/js/cannaiq-menus.js', CANNAIQ_MENUS_PLUGIN_URL . 'assets/js/cannaiq-menus.js',
@@ -203,15 +111,6 @@ class CannaIQ_Menus_Plugin {
CANNAIQ_MENUS_VERSION, CANNAIQ_MENUS_VERSION,
true true
); );
// Pass analytics config to JavaScript
$api_token = get_option('cannaiq_api_token');
wp_localize_script('cannaiq-menus-script', 'cannaiqAnalytics', [
'enabled' => !empty($api_token),
'api_url' => CANNAIQ_MENUS_API_URL,
'api_token' => $api_token,
'store_id' => get_option('cannaiq_default_store_id', 1),
]);
} }
/** /**
@@ -219,8 +118,8 @@ class CannaIQ_Menus_Plugin {
*/ */
public function add_admin_menu() { public function add_admin_menu() {
add_menu_page( add_menu_page(
'CannaiQ Menus', 'CannaIQ Menus',
'CannaiQ Menus', 'CannaIQ Menus',
'manage_options', 'manage_options',
'cannaiq-menus', 'cannaiq-menus',
[$this, 'admin_page'], [$this, 'admin_page'],
@@ -233,9 +132,7 @@ class CannaIQ_Menus_Plugin {
* Register Plugin Settings * Register Plugin Settings
*/ */
public function register_settings() { public function register_settings() {
register_setting('cannaiq_menus_settings', 'cannaiq_api_token', [ register_setting('cannaiq_menus_settings', 'cannaiq_api_token');
'sanitize_callback' => [$this, 'sanitize_api_token'],
]);
// MIGRATION: Auto-migrate API token from old Crawlsy plugin // MIGRATION: Auto-migrate API token from old Crawlsy plugin
$old_crawlsy_token = get_option('crawlsy_api_token'); $old_crawlsy_token = get_option('crawlsy_api_token');
@@ -244,46 +141,15 @@ class CannaIQ_Menus_Plugin {
} }
} }
/**
* Sanitize and validate API token, fetch dispensary info
*/
public function sanitize_api_token($token) {
$token = sanitize_text_field($token);
if (!empty($token)) {
// Fetch dispensary info from API
$response = wp_remote_get(CANNAIQ_MENUS_API_URL . '/menu', [
'headers' => [
'X-API-Key' => $token,
],
'timeout' => 15,
]);
if (!is_wp_error($response)) {
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (isset($data['dispensary'])) {
update_option('cannaiq_dispensary_name', sanitize_text_field($data['dispensary']));
}
if (isset($data['store_id'])) {
update_option('cannaiq_default_store_id', intval($data['store_id']));
}
}
}
return $token;
}
/** /**
* Admin Page * Admin Page
*/ */
public function admin_page() { public function admin_page() {
?> ?>
<div class="wrap"> <div class="wrap">
<h1>CannaiQ Menus Settings</h1> <h1>CannaIQ Menus Settings</h1>
<p>Version <?php echo CANNAIQ_MENUS_VERSION; ?> by <a href="https://cannaiq.co" target="_blank">CannaiQ</a></p> <p>Version <?php echo CANNAIQ_MENUS_VERSION; ?> by <a href="https://cannaiq.co" target="_blank">CannaIQ</a></p>
<p class="description">Display real-time cannabis menus with data updated daily from CannaiQ.</p> <p class="description">Display real-time cannabis menus with data updated daily from CannaIQ.</p>
<form method="post" action="options.php"> <form method="post" action="options.php">
<?php settings_fields('cannaiq_menus_settings'); ?> <?php settings_fields('cannaiq_menus_settings'); ?>
@@ -296,7 +162,7 @@ class CannaIQ_Menus_Plugin {
<input type="password" id="cannaiq_api_token" name="cannaiq_api_token" <input type="password" id="cannaiq_api_token" name="cannaiq_api_token"
value="<?php echo esc_attr(get_option('cannaiq_api_token')); ?>" value="<?php echo esc_attr(get_option('cannaiq_api_token')); ?>"
class="regular-text" /> class="regular-text" />
<p class="description">Your authentication token from the CannaiQ admin dashboard. The token includes your store configuration.</p> <p class="description">Your authentication token from the CannaIQ admin dashboard. The token includes your store configuration.</p>
</td> </td>
</tr> </tr>
</table> </table>
@@ -413,9 +279,8 @@ class CannaIQ_Menus_Plugin {
<hr /> <hr />
<h2>Usage</h2> <h2>Usage</h2>
<h3>Shortcodes</h3> <h3>Shortcodes</h3>
<table class="widefat" style="max-width: 900px;"> <table class="widefat" style="max-width: 800px;">
<thead> <thead>
<tr> <tr>
<th>Shortcode</th> <th>Shortcode</th>
@@ -425,175 +290,21 @@ class CannaIQ_Menus_Plugin {
<tbody> <tbody>
<tr> <tr>
<td><code>[cannaiq_products]</code></td> <td><code>[cannaiq_products]</code></td>
<td>Product grid. Options: <code>category</code>, <code>brand</code>, <code>limit</code>, <code>columns</code>, <code>in_stock</code></td> <td>Display a grid of products. Options: <code>category_id</code>, <code>limit</code>, <code>columns</code>, <code>in_stock</code></td>
</tr> </tr>
<tr> <tr>
<td><code>[cannaiq_product id="123"]</code></td> <td><code>[cannaiq_product id="123"]</code></td>
<td>Single product by ID</td> <td>Display a single product by ID</td>
</tr>
<tr>
<td><code>[cannaiq_specials]</code></td>
<td>Products on sale. Options: <code>limit</code>, <code>columns</code></td>
</tr>
<tr>
<td><code>[cannaiq_brands]</code></td>
<td>Brand grid. Options: <code>limit</code>, <code>columns</code></td>
</tr>
<tr>
<td><code>[cannaiq_categories]</code></td>
<td>Category list. Options: <code>style</code> (list|grid)</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<h4 style="margin-top: 20px;">Component Shortcodes <span style="color: #666; font-weight: normal;">(use inside product context)</span></h4> <h3>Elementor Widgets</h3>
<table class="widefat" style="max-width: 900px;"> <p>If you have Elementor installed, you can use the CannaIQ widgets:</p>
<thead>
<tr>
<th>Shortcode</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>[cannaiq_discount_badge]</code></td>
<td>Discount ribbon/pill. Options: <code>style</code> (ribbon|pill|text)</td>
</tr>
<tr>
<td><code>[cannaiq_strain_badge]</code></td>
<td>Sativa/Indica/Hybrid badge. Options: <code>style</code> (pill|text)</td>
</tr>
<tr>
<td><code>[cannaiq_thc]</code></td>
<td>THC percentage. Options: <code>style</code> (meter|badge|pill|text)</td>
</tr>
<tr>
<td><code>[cannaiq_cbd]</code></td>
<td>CBD percentage. Options: <code>style</code> (meter|badge|pill|text)</td>
</tr>
<tr>
<td><code>[cannaiq_effects]</code></td>
<td>Effect chips with icons. Options: <code>limit</code>, <code>icons</code> (yes|no)</td>
</tr>
<tr>
<td><code>[cannaiq_price]</code></td>
<td>Price display. Options: <code>show_original</code> (yes|no), <code>show_weight</code> (yes|no)</td>
</tr>
<tr>
<td><code>[cannaiq_cart_button]</code></td>
<td>Add to cart button. Options: <code>text</code>, <code>style</code> (solid|outline)</td>
</tr>
<tr>
<td><code>[cannaiq_stock]</code></td>
<td>Stock status. Options: <code>style</code> (badge|text|dot)</td>
</tr>
<tr>
<td><code>[cannaiq_terpenes]</code></td>
<td>Terpene profile. Options: <code>limit</code>, <code>style</code> (chips|list|text)</td>
</tr>
</tbody>
</table>
<h3 style="margin-top: 30px;">Elementor Widgets</h3>
<p>With Elementor installed, find CannaiQ widgets in the editor:</p>
<h4>Layout Widgets</h4>
<ul style="list-style: disc; margin-left: 20px;"> <ul style="list-style: disc; margin-left: 20px;">
<li><strong>Product Grid</strong> - Filterable product grid</li> <li><strong>CannaIQ Product Grid</strong> - Display a grid of products with filtering options</li>
<li><strong>Product Loop</strong> - Custom loop for building cards</li> <li><strong>CannaIQ Single Product</strong> - Display a single product card</li>
<li><strong>Single Product</strong> - Display one product</li>
<li><strong>Brand Grid</strong> - Display brands</li>
<li><strong>Category List</strong> - Display categories</li>
<li><strong>Specials/Deals</strong> - Products on sale</li>
</ul> </ul>
<h4>Component Widgets <span style="color: #666; font-weight: normal;">(v2.0)</span></h4>
<ul style="list-style: disc; margin-left: 20px;">
<li><strong>Discount Ribbon</strong> - Sale percentage badge</li>
<li><strong>Strain Badge</strong> - Sativa/Indica/Hybrid pill</li>
<li><strong>THC/CBD Meter</strong> - Potency display</li>
<li><strong>Effects Display</strong> - Effect chips with icons</li>
<li><strong>Price Block</strong> - Price with sale formatting</li>
<li><strong>Cart Button</strong> - Styled CTA button</li>
<li><strong>Stock Indicator</strong> - Availability badge</li>
<li><strong>Product Image + Badges</strong> - Image with overlays</li>
</ul>
<h4>Card Templates <span style="color: #666; font-weight: normal;">(v2.0)</span></h4>
<ul style="list-style: disc; margin-left: 20px;">
<li><strong>Premium Product Card</strong> - Ready-to-use card with all components</li>
</ul>
<h3 style="margin-top: 30px;">Dynamic Tags</h3>
<p>In Elementor, use dynamic tags to insert product data into any widget. Look for "CannaiQ Product" in the dynamic tags menu.</p>
<hr style="margin: 30px 0;" />
<h2>How to Build a Product Card</h2>
<p>Use the modular components to build custom product cards. Here's an example layout:</p>
<div style="display: flex; gap: 30px; flex-wrap: wrap; margin-top: 20px;">
<!-- Visual Example -->
<div style="background: #fff; border: 2px solid #ddd; border-radius: 12px; width: 300px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Image area -->
<div style="background: linear-gradient(135deg, #f0f0f0 0%, #e0e0e0 100%); height: 200px; position: relative;">
<span style="position: absolute; top: 0; left: 0; background: #ef4444; color: white; padding: 4px 12px; font-size: 12px; font-weight: bold; border-bottom-right-radius: 8px;">67% OFF</span>
<div style="position: absolute; bottom: 8px; left: 8px; display: flex; gap: 4px;">
<span style="background: #22c55e; color: white; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: bold;">SATIVA</span>
<span style="background: #1f2937; color: white; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: bold;">24.5% THC</span>
</div>
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #999; font-size: 48px;">🌿</div>
</div>
<!-- Body -->
<div style="padding: 16px;">
<h4 style="margin: 0 0 4px 0; font-size: 18px;">Hot Lava</h4>
<p style="margin: 0 0 12px 0; color: #666; font-size: 14px;">by TruInfusion</p>
<div style="display: flex; gap: 6px; margin-bottom: 12px;">
<span style="background: #fef3c7; border: 1px solid #fcd34d; padding: 2px 8px; border-radius: 999px; font-size: 11px;">😴 Sleepy</span>
<span style="background: #dbeafe; border: 1px solid #93c5fd; padding: 2px 8px; border-radius: 999px; font-size: 11px;">😌 Relaxed</span>
<span style="background: #fce7f3; border: 1px solid #f9a8d4; padding: 2px 8px; border-radius: 999px; font-size: 11px;">😊 Happy</span>
</div>
<div style="margin-bottom: 12px;">
<span style="color: #999; font-size: 14px;">1/8 oz</span>
<span style="color: #999; text-decoration: line-through; margin-left: 8px;">$45.00</span>
<span style="color: #dc2626; font-weight: bold; font-size: 18px; margin-left: 8px;">$15.00</span>
</div>
<div style="background: #1f2937; color: white; text-align: center; padding: 10px; border-radius: 6px; font-weight: bold; font-size: 14px;">ADD TO CART →</div>
</div>
</div>
<!-- Component Labels -->
<div style="flex: 1; min-width: 300px;">
<h4 style="margin-top: 0;">Components Used:</h4>
<table class="widefat" style="max-width: 400px;">
<tr><td style="width: 40%;"><strong>Discount Ribbon</strong></td><td>Top-left corner badge</td></tr>
<tr><td><strong>Product Image</strong></td><td>With badge overlays</td></tr>
<tr><td><strong>Strain Badge</strong></td><td>Green Sativa pill</td></tr>
<tr><td><strong>THC Badge</strong></td><td>Dark potency pill</td></tr>
<tr><td><strong>Product Name</strong></td><td>Dynamic tag</td></tr>
<tr><td><strong>Brand Name</strong></td><td>Dynamic tag</td></tr>
<tr><td><strong>Effects Display</strong></td><td>Colored chips with icons</td></tr>
<tr><td><strong>Price Block</strong></td><td>Weight + strikethrough + sale</td></tr>
<tr><td><strong>Cart Button</strong></td><td>Links to dispensary menu</td></tr>
</table>
<h4 style="margin-top: 20px;">Build Steps:</h4>
<ol style="margin-left: 20px; line-height: 1.8;">
<li>Add a <strong>Product Loop</strong> widget</li>
<li>Inside the loop, add a container</li>
<li>Add <strong>Product Image + Badges</strong> widget</li>
<li>Add heading with <strong>Product Name</strong> dynamic tag</li>
<li>Add text with <strong>Brand Name</strong> dynamic tag</li>
<li>Add <strong>Effects Display</strong> widget</li>
<li>Add <strong>Price Block</strong> widget</li>
<li>Add <strong>Cart Button</strong> widget</li>
</ol>
<p style="margin-top: 20px; padding: 12px; background: #e7f3ff; border-left: 4px solid #2196f3; border-radius: 4px;">
<strong>Tip:</strong> Use the <strong>Premium Product Card</strong> template widget for a ready-to-use version of this layout!
</p>
</div>
</div>
</div> </div>
<?php <?php
} }
@@ -643,487 +354,6 @@ class CannaIQ_Menus_Plugin {
return ob_get_clean(); return ob_get_clean();
} }
/**
* Specials Shortcode
*/
public function specials_shortcode($atts) {
$atts = shortcode_atts([
'limit' => 12,
'columns' => 3
], $atts);
$products = $this->fetch_specials($atts);
if (!$products) {
return '<p>No specials found.</p>';
}
ob_start();
include CANNAIQ_MENUS_PLUGIN_DIR . 'templates/product-grid.php';
return ob_get_clean();
}
/**
* Brands Shortcode
*/
public function brands_shortcode($atts) {
$atts = shortcode_atts([
'limit' => 20,
'columns' => 4
], $atts);
$brands = $this->fetch_brands($atts);
if (!$brands) {
return '<p>No brands found.</p>';
}
$columns = intval($atts['columns']);
ob_start();
?>
<div class="cannaiq-brands-grid" style="display: grid; grid-template-columns: repeat(<?php echo $columns; ?>, 1fr); gap: 20px;">
<?php foreach ($brands as $brand): ?>
<div class="cannaiq-brand-card" style="text-align: center; padding: 20px; background: #f9fafb; border-radius: 8px;">
<?php if (!empty($brand['logo'])): ?>
<img src="<?php echo esc_url($brand['logo']); ?>" alt="<?php echo esc_attr($brand['brand'] ?? $brand['name']); ?>" style="max-height: 60px; margin-bottom: 10px;" />
<?php endif; ?>
<h4 style="margin: 0;"><?php echo esc_html($brand['brand'] ?? $brand['name']); ?></h4>
<?php if (!empty($brand['product_count'])): ?>
<p style="margin: 5px 0 0; color: #666; font-size: 14px;"><?php echo intval($brand['product_count']); ?> products</p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php
return ob_get_clean();
}
/**
* Categories Shortcode
*/
public function categories_shortcode($atts) {
$atts = shortcode_atts([
'style' => 'list'
], $atts);
$categories = $this->fetch_categories();
if (!$categories) {
return '<p>No categories found.</p>';
}
ob_start();
if ($atts['style'] === 'grid') {
?>
<div class="cannaiq-categories-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px;">
<?php foreach ($categories as $cat): ?>
<div class="cannaiq-category-card" style="padding: 15px; background: #f9fafb; border-radius: 8px; text-align: center;">
<h4 style="margin: 0;"><?php echo esc_html(ucwords(str_replace('_', ' ', $cat['type'] ?? $cat['name']))); ?></h4>
<?php if (!empty($cat['count'])): ?>
<p style="margin: 5px 0 0; color: #666; font-size: 14px;"><?php echo intval($cat['count']); ?> products</p>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php
} else {
?>
<ul class="cannaiq-categories-list" style="list-style: none; padding: 0; margin: 0;">
<?php foreach ($categories as $cat): ?>
<li style="padding: 10px 0; border-bottom: 1px solid #eee;">
<?php echo esc_html(ucwords(str_replace('_', ' ', $cat['type'] ?? $cat['name']))); ?>
<?php if (!empty($cat['count'])): ?>
<span style="color: #666; font-size: 14px;">(<?php echo intval($cat['count']); ?>)</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php
}
return ob_get_clean();
}
/**
* Discount Badge Shortcode
*/
public function discount_badge_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['style' => 'ribbon'], $atts);
$product = $cannaiq_current_product;
$original = $product['Prices'][0] ?? $product['regular_price'] ?? null;
$sale = $product['specialPrice'] ?? $product['sale_price'] ?? null;
if (!$original || !$sale || $original <= $sale) return '';
$percent = round((($original - $sale) / $original) * 100);
$class = 'cannaiq-discount-ribbon cannaiq-discount-ribbon--' . esc_attr($atts['style']);
return sprintf('<span class="%s">%s%% OFF</span>', $class, $percent);
}
/**
* Strain Badge Shortcode
*/
public function strain_badge_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['style' => 'pill'], $atts);
$strain = strtolower($cannaiq_current_product['strainType'] ?? $cannaiq_current_product['strain_type'] ?? '');
if (empty($strain) || !in_array($strain, ['sativa', 'indica', 'hybrid'])) return '';
$colors = ['sativa' => '#22c55e', 'indica' => '#8b5cf6', 'hybrid' => '#f97316'];
$color = $colors[$strain];
$style = $atts['style'] === 'pill' ? "background-color: {$color}; color: white;" : "color: {$color};";
return sprintf('<span class="cannaiq-strain-badge cannaiq-strain-badge--%s" style="%s">%s</span>',
esc_attr($atts['style']), esc_attr($style), esc_html(strtoupper($strain)));
}
/**
* THC Shortcode
*/
public function thc_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['style' => 'badge'], $atts);
$thc = $cannaiq_current_product['THCContent']['range'][0] ?? $cannaiq_current_product['THC'] ?? $cannaiq_current_product['thc_percentage'] ?? null;
if (!$thc || $thc <= 0) return '';
$formatted = number_format((float)$thc, 1) . '% THC';
return sprintf('<span class="cannaiq-potency-badge cannaiq-potency-badge--%s">%s</span>',
esc_attr($atts['style']), esc_html($formatted));
}
/**
* CBD Shortcode
*/
public function cbd_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['style' => 'badge'], $atts);
$cbd = $cannaiq_current_product['CBDContent']['range'][0] ?? $cannaiq_current_product['CBD'] ?? $cannaiq_current_product['cbd_percentage'] ?? null;
if (!$cbd || $cbd <= 0) return '';
$formatted = number_format((float)$cbd, 1) . '% CBD';
return sprintf('<span class="cannaiq-potency-badge cannaiq-potency-badge--%s">%s</span>',
esc_attr($atts['style']), esc_html($formatted));
}
/**
* Effects Shortcode
*/
public function effects_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['limit' => 3, 'icons' => 'yes'], $atts);
$effects = $cannaiq_current_product['effects'] ?? [];
if (empty($effects) || !is_array($effects)) return '';
if (!isset($effects[0])) {
arsort($effects);
$effects = array_keys($effects);
}
$effects = array_slice($effects, 0, intval($atts['limit']));
return cannaiq_render_effects($effects, [
'limit' => intval($atts['limit']),
'show_icon' => $atts['icons'] === 'yes',
'size' => 'medium'
]);
}
/**
* Price Shortcode
*/
public function price_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['show_original' => 'yes', 'show_weight' => 'yes'], $atts);
$product = $cannaiq_current_product;
$original = $product['Prices'][0] ?? $product['regular_price'] ?? null;
$sale = $product['specialPrice'] ?? $product['sale_price'] ?? null;
$weight = $product['Options'][0] ?? $product['weight'] ?? '';
if (!$original || $original <= 0) return '';
$is_sale = $sale && $sale > 0 && $sale < $original;
ob_start();
?>
<span class="cannaiq-price-block">
<?php if ($atts['show_weight'] === 'yes' && !empty($weight)): ?>
<span class="cannaiq-price-block__weight"><?php echo esc_html($weight); ?></span>
<?php endif; ?>
<?php if ($is_sale): ?>
<?php if ($atts['show_original'] === 'yes'): ?>
<span class="cannaiq-price-block__original">$<?php echo number_format((float)$original, 2); ?></span>
<?php endif; ?>
<span class="cannaiq-price-block__sale">$<?php echo number_format((float)$sale, 2); ?></span>
<?php else: ?>
<span class="cannaiq-price-block__regular">$<?php echo number_format((float)$original, 2); ?></span>
<?php endif; ?>
</span>
<?php
return ob_get_clean();
}
/**
* Cart Button Shortcode - Tracked button that links to dispensary menu
*
* Usage: [cannaiq_cart_button text="Shop Now" style="solid" class="my-custom-class"]
*
* Attributes:
* text - Button text (default: "ADD TO CART")
* style - Button style: solid, outline (default: solid)
* class - Additional CSS classes
* size - Button size: small, medium, large (default: medium)
* full - Full width: yes/no (default: no)
*
* Tracking includes: product_id, product_name, price, category, on_special, store_id
*/
public function cart_button_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts([
'text' => 'ADD TO CART',
'style' => 'solid',
'class' => '',
'size' => '',
'full' => 'no',
], $atts);
$product = $cannaiq_current_product;
$url = $product['menuUrl'] ?? $product['menu_url'] ?? '#';
// Extract tracking data
$product_id = $product['id'] ?? '';
$product_name = $product['name'] ?? $product['Name'] ?? '';
$store_id = $product['dispensary_id'] ?? $product['store_id'] ?? get_option('cannaiq_default_store_id', '');
$category = $product['category'] ?? $product['type'] ?? $product['Category'] ?? '';
$price = $product['sale_price'] ?? $product['regular_price'] ?? $product['price'] ?? '';
$on_special = !empty($product['on_special']) || !empty($product['special']) || !empty($product['sale_price']);
// Build classes
$classes = ['cannaiq-cart-button', 'cannaiq-cart-button--' . esc_attr($atts['style'])];
if ($atts['size']) {
$classes[] = 'cannaiq-cart-button--' . esc_attr($atts['size']);
}
if ($atts['full'] === 'yes') {
$classes[] = 'cannaiq-cart-button--full';
}
if ($atts['class']) {
$classes[] = esc_attr($atts['class']);
}
// Build data attributes for tracking
$data_attrs = sprintf(
'data-product-id="%s" data-product-name="%s" data-store-id="%s" data-category="%s" data-product-price="%s" data-on-special="%s"',
esc_attr($product_id),
esc_attr($product_name),
esc_attr($store_id),
esc_attr($category),
esc_attr($price),
$on_special ? 'true' : 'false'
);
return sprintf(
'<a href="%s" class="%s" %s target="_blank" rel="noopener">%s</a>',
esc_url($url),
implode(' ', $classes),
$data_attrs,
esc_html($atts['text'])
);
}
/**
* Stock Shortcode
*/
public function stock_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['style' => 'badge'], $atts);
$status = $cannaiq_current_product['Status'] ?? '';
$in_stock = ($status === 'Active' || $status === 'In Stock' || !empty($cannaiq_current_product['in_stock']));
$text = $in_stock ? 'In Stock' : 'Out of Stock';
$class = 'cannaiq-stock-indicator cannaiq-stock-indicator--' . ($in_stock ? 'in-stock' : 'out-of-stock');
if ($atts['style'] === 'badge') $class .= ' cannaiq-stock-indicator--badge';
$dot = $atts['style'] === 'dot' ? '<span class="cannaiq-stock-indicator__dot"></span>' : '';
return sprintf('<span class="%s">%s%s</span>', esc_attr($class), $dot, esc_html($text));
}
/**
* Terpenes Shortcode
*/
public function terpenes_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['limit' => 3, 'style' => 'chips'], $atts);
$terpenes = $cannaiq_current_product['terpenes'] ?? [];
if (empty($terpenes) || !is_array($terpenes)) return '';
$terpenes = array_slice($terpenes, 0, intval($atts['limit']));
ob_start();
if ($atts['style'] === 'chips') {
echo '<div class="cannaiq-terpenes cannaiq-terpenes--chips">';
foreach ($terpenes as $terp) {
$name = $terp['name'] ?? '';
$percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : '';
printf('<span class="cannaiq-terpene-chip"><span class="cannaiq-terpene-chip__name">%s</span><span class="cannaiq-terpene-chip__percent">%s</span></span>',
esc_html($name), esc_html($percent));
}
echo '</div>';
} elseif ($atts['style'] === 'text') {
$parts = [];
foreach ($terpenes as $terp) {
$name = $terp['name'] ?? '';
$percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : '';
$parts[] = $name . ($percent ? ' ' . $percent : '');
}
echo esc_html(implode(', ', $parts));
} else {
echo '<div class="cannaiq-terpenes cannaiq-terpenes--list">';
foreach ($terpenes as $terp) {
$name = $terp['name'] ?? '';
$percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : '';
printf('<div class="cannaiq-terpene-item"><span>%s</span><span>%s</span></div>',
esc_html($name), esc_html($percent));
}
echo '</div>';
}
return ob_get_clean();
}
/**
* Product Wrapper Shortcode - Wraps content with product data attributes for tracking
*
* Usage: [cannaiq_product_wrapper]<your custom button>[/cannaiq_product_wrapper]
*
* This enables click tracking on any custom button inside by providing
* product data attributes that the tracking JS can read.
*
* Inside the wrapper, add class "cannaiq-cart-button" to any element
* that should trigger add_to_cart tracking.
*/
public function product_wrapper_shortcode($atts, $content = null) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) {
return do_shortcode($content);
}
$atts = shortcode_atts([
'class' => '',
'tag' => 'div',
], $atts);
$product = $cannaiq_current_product;
// Extract product data
$product_id = $product['id'] ?? '';
$product_name = $product['name'] ?? $product['Name'] ?? '';
$store_id = $product['dispensary_id'] ?? $product['store_id'] ?? get_option('cannaiq_default_store_id', '');
$category = $product['category'] ?? $product['type'] ?? $product['Category'] ?? '';
$price = $product['sale_price'] ?? $product['regular_price'] ?? $product['price'] ?? '';
$menu_url = $product['menu_url'] ?? $product['menuUrl'] ?? '';
// Build data attributes
$data_attrs = sprintf(
'data-product-id="%s" data-product-name="%s" data-store-id="%s" data-category="%s" data-product-price="%s" data-menu-url="%s"',
esc_attr($product_id),
esc_attr($product_name),
esc_attr($store_id),
esc_attr($category),
esc_attr($price),
esc_attr($menu_url)
);
$tag = in_array($atts['tag'], ['div', 'span', 'section', 'article']) ? $atts['tag'] : 'div';
$class = 'cannaiq-product-item' . ($atts['class'] ? ' ' . esc_attr($atts['class']) : '');
return sprintf(
'<%1$s class="%2$s" %3$s>%4$s</%1$s>',
$tag,
$class,
$data_attrs,
do_shortcode($content)
);
}
/**
* Track Shortcode - Wraps content with click tracking
*
* Usage: [cannaiq_track event="banner_click"]<div>Your content</div>[/cannaiq_track]
*
* Attributes:
* event - (required) Event name for tracking (e.g., "shop_now", "banner_click")
* category - (optional) Category for grouping events
* label - (optional) Additional label
* value - (optional) Numeric value
* tag - (optional) HTML tag to use (default: "div")
* class - (optional) Additional CSS classes
*/
public function track_shortcode($atts, $content = null) {
$atts = shortcode_atts([
'event' => '',
'category' => '',
'label' => '',
'value' => '',
'tag' => 'div',
'class' => '',
], $atts);
if (empty($atts['event'])) {
return do_shortcode($content);
}
// Sanitize tag to allowed elements only
$allowed_tags = ['div', 'span', 'a', 'button', 'section', 'article'];
$tag = in_array($atts['tag'], $allowed_tags) ? $atts['tag'] : 'div';
// Build data attributes
$data_attrs = sprintf('data-cannaiq-track="%s"', esc_attr($atts['event']));
if (!empty($atts['category'])) {
$data_attrs .= sprintf(' data-cannaiq-track-category="%s"', esc_attr($atts['category']));
}
if (!empty($atts['label'])) {
$data_attrs .= sprintf(' data-cannaiq-track-label="%s"', esc_attr($atts['label']));
}
if (!empty($atts['value'])) {
$data_attrs .= sprintf(' data-cannaiq-track-value="%s"', esc_attr($atts['value']));
}
// Build class attribute
$class_attr = !empty($atts['class']) ? sprintf(' class="%s"', esc_attr($atts['class'])) : '';
return sprintf(
'<%1$s %2$s%3$s>%4$s</%1$s>',
$tag,
$data_attrs,
$class_attr,
do_shortcode($content)
);
}
/** /**
* Fetch Products from API * Fetch Products from API
*/ */

View File

@@ -1,463 +0,0 @@
# CannaiQ Menus - Elementor Guide
Build custom cannabis product menus using Elementor and CannaiQ dynamic tags.
---
## Quick Start
### 1. Install & Configure
1. Install the CannaiQ Menus plugin
2. Go to **Settings → CannaiQ Menus**
3. Enter your **API Key** (provided by CannaiQ)
4. Enter your **Store ID** (your dispensary ID)
5. Save settings
### 2. Create Your First Menu
1. Create a new page in WordPress
2. Click **Edit with Elementor**
3. Search for **"CannaiQ Product Loop"** widget
4. Drag it onto your page
5. Products will appear automatically
---
## Using Elementor Loop Grid (Recommended)
The best way to display CannaiQ products is with Elementor's **Loop Grid** widget. This gives you full design control with your own Loop Item templates.
### Data Sources
When you add a Loop Grid widget, select your data source from the Query dropdown:
| Source | Description |
|--------|-------------|
| **{Your Store} Products** | All products from your dispensary |
| **{Your Store} Specials** | Only products currently on sale |
The source name shows your dispensary name (e.g., "Deeply Rooted Products").
### Available Filters
All filters work with both Products and Specials sources:
| Filter | Options | Description |
|--------|---------|-------------|
| **Category** | Flower, Vape, Edibles, Concentrates, Pre-Rolls, Tinctures, Topicals, Accessories | Filter by product type |
| **Brand** | Text input | Filter by brand name |
| **Strain Type** | Sativa, Indica, Hybrid | Filter cannabis strains |
| **On Special Only** | Yes/No | Show only sale items (Products source only) |
| **In Stock Only** | Yes/No | Hide out-of-stock items (default: Yes) |
| **Sort By** | Name, Price, THC %, Recently Updated | Order products |
| **Sort Direction** | Ascending, Descending | Sort order |
| **Limit** | 1-100 | Maximum products to display |
### Example: Flower Specials Grid
1. Add **Loop Grid** widget
2. Set **Query Source** → "{Your Store} Specials"
3. Set **Category** → "Flower"
4. Set **Limit** → 8
5. Set **Sort By** → "Price", **Direction** → "Ascending"
This shows the 8 cheapest flower products currently on sale.
### Example: Top THC Products
1. Add **Loop Grid** widget
2. Set **Query Source** → "{Your Store} Products"
3. Set **Category** → "Flower"
4. Set **Sort By** → "THC %", **Direction** → "Descending"
5. Set **Limit** → 12
This shows the 12 highest THC flower products.
### Example: Brand Showcase
1. Add **Loop Grid** widget
2. Set **Query Source** → "{Your Store} Products"
3. Set **Brand** → "Cookies" (or any brand name)
4. Set **In Stock Only** → Yes
This shows all in-stock products from a specific brand.
### Example: Brand Specials
1. Add **Loop Grid** widget
2. Set **Query Source** → "{Your Store} Specials"
3. Set **Brand** → "Cookies" (or any brand name)
This shows all products from a specific brand that are currently on sale.
### Example: Indica Edibles on Sale
1. Add **Loop Grid** widget
2. Set **Query Source** → "{Your Store} Specials"
3. Set **Category** → "Edibles"
4. Set **Strain Type** → "Indica"
This shows indica edibles currently on sale - combine any filters!
### Creating Loop Item Templates
1. Go to **Elementor → Templates → Loop Item**
2. Click **Add New**
3. Design your product card using CannaiQ dynamic tags
4. Save the template
5. In your Loop Grid, select your template under **Layout → Template**
### Adding "Add to Cart" Button with Tracking
All outbound clicks to dispensary menus (Dutchie, iHeartJane, Treez, etc.) are **automatically tracked**.
#### Method 1: Shortcode Button (Easiest)
Use the cart button shortcode for a fully tracked button:
```
[cannaiq_cart_button text="ADD TO CART" style="solid"]
```
**Options:**
| Attribute | Values | Default |
|-----------|--------|---------|
| `text` | Any text | "ADD TO CART" |
| `style` | solid, outline | solid |
| `size` | small, medium, large | medium |
| `full` | yes, no | no |
| `class` | Custom CSS classes | - |
**Examples:**
```
[cannaiq_cart_button text="Shop Now" style="outline"]
[cannaiq_cart_button text="BUY" size="large" full="yes"]
[cannaiq_cart_button text="Order" class="my-custom-button"]
```
**Tracked data:** Product ID, Name, Price, Category, On Special, Store ID, Destination URL
#### Method 2: Custom Elementor Button
For full design control with Elementor:
1. Add the **product wrapper** shortcode around your content:
```
[cannaiq_product_wrapper]
<!-- Your Elementor widgets here -->
[/cannaiq_product_wrapper]
```
2. Add an **Elementor Button** widget inside
3. Style it however you want
4. Set **Link** → Dynamic Tag → **CannaiQ Product → Menu URL**
5. (Optional) Add class `cannaiq-cart-button` in Advanced → CSS Classes
#### Automatic Tracking
The plugin automatically tracks clicks on ANY link going to:
- dutchie.com
- iheartjane.com
- jane.com
- treez.io
- weedmaps.com
- leafly.com
**No extra setup needed** - if the link goes to a menu provider, it's tracked.
**Note:** CannaiQ products link to your dispensary's online ordering system. The button takes customers to where they can complete their purchase.
---
## Building Custom Product Cards
The power of CannaiQ is building your own card designs. Here's how:
### Step 1: Add Product Loop
The Product Loop is your container. It fetches products and repeats your design for each one.
1. Drag **CannaiQ Product Loop** onto page
2. Configure settings:
- **Store ID**: Your dispensary ID
- **Category**: Filter by Flower, Vape, Edibles, etc.
- **Limit**: How many products to show
- **On Special Only**: Show only sale items
### Step 2: Design Your Card
Inside the Product Loop, add standard Elementor elements:
```
Product Loop
└── Container (your card wrapper)
├── Image
├── Heading (product name)
├── Text (brand)
├── Text (THC/effects)
├── Text (price)
└── Button (add to cart)
```
### Step 3: Connect Dynamic Tags
This is the key step. Each element needs to pull data from the product.
1. Click on any element (e.g., Heading)
2. Find the field you want to populate (e.g., "Title")
3. Click the **Dynamic Tags** icon (stacked coins icon)
4. Select **CannaiQ Product****Product Name**
Repeat for each element in your card.
---
## Dynamic Tags Reference
Click the Dynamic Tags icon and select from **CannaiQ Product** group:
### Basic Info
| Tag | Output Example |
|-----|----------------|
| Product Name | "Blue Dream" |
| Product ID | "12345" |
| Store ID | "1" |
| Brand Name | "TruInfusion" |
| Category | "Flower" |
| Subcategory | "Eighths" |
| Description | "A sativa-dominant hybrid..." |
### Pricing
| Tag | Output Example |
|-----|----------------|
| Price | "$45.00" |
| Sale Price | "$35.00" |
| Original Price | "$45.00" |
| Price Display | "$45.00 → $35.00" (with strikethrough) |
| Discount % | "22% OFF" |
| Discount Badge | Red badge with "22% OFF" |
### Potency
| Tag | Output Example |
|-----|----------------|
| THC % | "24.5%" |
| THC Badge | Dark pill with "24.5% THC" |
| CBD % | "0.5%" |
| CBD Badge | Dark pill with "0.5% CBD" |
| Strain Type | "Sativa" |
| Strain Badge | Green pill with "SATIVA" |
### Effects & Details
| Tag | Output Example |
|-----|----------------|
| Effects Chips | Colored pills: 😊 HAPPY 😌 RELAXED |
| Single Effect | One effect pill |
| Terpenes | "Myrcene 1.2%, Limonene 0.8%" |
| Weight | "1/8 oz" |
| Weight Options | Pill selector or dropdown |
### Stock
| Tag | Output Example |
|-----|----------------|
| Stock Status | Green badge "In Stock" |
| Stock Quantity | "24" |
| In Stock | "In Stock" or "Out of Stock" |
### Links & Images
| Tag | Use For |
|-----|---------|
| Product Image | Image widget source |
| Menu URL | Button link (opens dispensary menu) |
| Brand Logo | Brand image |
---
## Example: Premium Product Card
Here's how to build a professional product card:
### Structure
```
Container (card wrapper)
├── Container (image area)
│ ├── Text: Discount Badge [Dynamic: Discount Badge]
│ ├── Image [Dynamic: Product Image]
│ └── Container (badge row)
│ ├── Text [Dynamic: Strain Badge]
│ └── Text [Dynamic: THC Badge]
└── Container (content area)
├── Heading [Dynamic: Product Name]
├── Text: "by [Dynamic: Brand Name]"
├── Text [Dynamic: Effects Chips]
├── Container (price row)
│ ├── Text [Dynamic: Weight]
│ └── Text [Dynamic: Price Display]
└── Button: "ADD TO CART" [Link: Dynamic Menu URL]
```
### Card Wrapper Settings
- Width: 280px
- Background: White
- Border Radius: 12px
- Box Shadow: 0 4px 20px rgba(0,0,0,0.08)
- Padding: 0 (image bleeds to edge)
- Overflow: Hidden
### Image Area Settings
- Min Height: 200px
- Background: #f9fafb
- Position: Relative (for badge overlays)
### Content Area Settings
- Padding: 16px
- Gap: 8px
### Button Settings
- Full Width: Yes
- Background: #1f2937 (dark)
- Text: White
- Border Radius: 6px
- Open in New Tab: Yes
---
## Example: Promo Banner
For promotional banners and deals:
### Structure
```
Container (banner)
├── Container (text side)
│ ├── Heading: "BOGO"
│ ├── Heading: "1G CONCENTRATES"
│ └── Button: "Shop Now"
└── Container (image side)
└── Image (product photo)
```
### Banner Settings
- Direction: Horizontal (Row)
- Background: Dark (#1a1a2e) or gradient
- Min Height: 140px
- Padding: 24px 32px
- Border Radius: 16px
- Justify: Space Between
- Align: Center
---
## Example: Horizontal Product Row
For list-style layouts:
### Structure
```
Container (row)
├── Image (thumbnail, 80x80px)
├── Container (details)
│ ├── Heading [Dynamic: Product Name]
│ ├── Text [Dynamic: Brand Name]
│ └── Text: "THC: [Dynamic: THC %]"
└── Container (price/action)
├── Text [Dynamic: Price Display]
└── Button: "+" (add to cart)
```
### Row Settings
- Direction: Horizontal (Row)
- Background: White
- Border Bottom: 1px solid #e5e7eb
- Padding: 16px 20px
- Gap: 16px
- Align: Center
---
## Using Pre-built Templates
We include ready-to-use templates you can import:
### How to Import
1. Go to **Elementor → Templates → Import Templates**
2. Navigate to: `wp-content/plugins/cannaiq-menus/templates/elementor/`
3. Select a template:
- `premium-card.json` - Vertical product card
- `promo-banner.json` - Horizontal deal banner
- `horizontal-row.json` - Product list row
4. Click Import
5. Insert template into your page
6. Customize colors, fonts, spacing
---
## Alternative: Pre-built Widgets
If you don't want to build custom designs, use our ready-made widgets:
| Widget | Description |
|--------|-------------|
| CannaiQ Product Grid | Grid of product cards |
| CannaiQ Specials Grid | Products on sale |
| CannaiQ Brand Grid | Browse by brand |
| CannaiQ Category List | Category navigation |
Just drag, configure Store ID, and publish.
---
## Alternative: Shortcodes
For non-Elementor pages or posts:
```
[cannaiq_products store_id="1" limit="12" category="Flower"]
[cannaiq_specials store_id="1" limit="6"]
[cannaiq_brands store_id="1"]
[cannaiq_categories store_id="1"]
```
### Shortcode Parameters
| Parameter | Description | Example |
|-----------|-------------|---------|
| store_id | Your dispensary ID | `store_id="1"` |
| limit | Max products | `limit="12"` |
| category | Filter category | `category="Flower"` |
| brand | Filter brand | `brand="Cookies"` |
| on_special | Sale items only | `on_special="true"` |
---
## Troubleshooting
### Dynamic tags not showing data?
- Make sure Product Loop is the parent container
- Check that Store ID is configured in plugin settings
- Verify API key is valid
### Products not loading?
- Check Settings → CannaiQ Menus for API connection status
- Verify your Store ID is correct
- Check browser console for errors
### Styles look wrong?
- Clear Elementor cache: Elementor → Tools → Regenerate CSS
- Clear any caching plugins
- Check for CSS conflicts with your theme
---
## Need Help?
- Documentation: https://cannaiq.co/docs
- Support: support@cannaiq.co

View File

@@ -1,192 +0,0 @@
<?php
/**
* Effects Icons Library
*
* SVG icons for cannabis effects display.
* Used by Effects Display widget and dynamic tags.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Get SVG icon for an effect
*
* @param string $effect Effect name (case-insensitive)
* @param array $args Optional args: size, class, color
* @return string SVG HTML or empty string if not found
*/
function cannaiq_get_effect_icon($effect, $args = []) {
$defaults = [
'size' => 16,
'class' => '',
'color' => 'currentColor',
];
$args = wp_parse_args($args, $defaults);
$effect_key = strtolower(trim($effect));
$icons = cannaiq_get_effect_icons();
if (!isset($icons[$effect_key])) {
return '';
}
$svg = $icons[$effect_key];
$size = intval($args['size']);
$class = esc_attr($args['class']);
$color = esc_attr($args['color']);
// Replace placeholders in SVG
$svg = str_replace(
['{SIZE}', '{CLASS}', '{COLOR}'],
[$size, $class, $color],
$svg
);
return $svg;
}
/**
* Get all effect icons
*
* @return array Associative array of effect => SVG
*/
function cannaiq_get_effect_icons() {
return [
'happy' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>',
'relaxed' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>',
'sleepy' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>',
'euphoric' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
'creative' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M12 2v4"/><path d="m6.34 6.34 2.83 2.83"/><path d="M2 12h4"/><path d="m6.34 17.66 2.83-2.83"/><path d="M12 18v4"/><path d="m17.66 17.66-2.83-2.83"/><path d="M18 12h4"/><path d="m17.66 6.34-2.83 2.83"/></svg>',
'energetic' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
'focused' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>',
'hungry' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>',
'uplifted' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="m18 15-6-6-6 6"/><path d="m18 9-6-6-6 6"/></svg>',
'talkative' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>',
'giggly' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><path d="M9 9h.01"/><path d="M15 9h.01"/></svg>',
'aroused' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>',
'tingly' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h2"/><path d="M8 17h2"/><path d="M14 13h2"/><path d="M14 17h2"/></svg>',
'calm' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z"/></svg>',
'sedated' => '<svg width="{SIZE}" height="{SIZE}" viewBox="0 0 24 24" fill="none" stroke="{COLOR}" stroke-width="2" class="{CLASS}"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/><path d="M9 10h.01"/><path d="M15 10h.01"/><path d="M10 16s.5-1 2-1 2 1 2 1"/></svg>',
];
}
/**
* Get effect color
*
* @param string $effect Effect name
* @return string Hex color code
*/
function cannaiq_get_effect_color($effect) {
$colors = [
'happy' => '#FFD700', // Gold
'relaxed' => '#87CEEB', // Sky blue
'sleepy' => '#9370DB', // Medium purple
'euphoric' => '#FF69B4', // Hot pink
'creative' => '#FF8C00', // Dark orange
'energetic' => '#32CD32', // Lime green
'focused' => '#4169E1', // Royal blue
'hungry' => '#FF6347', // Tomato
'uplifted' => '#00CED1', // Dark turquoise
'talkative' => '#DDA0DD', // Plum
'giggly' => '#FFB6C1', // Light pink
'aroused' => '#DC143C', // Crimson
'tingly' => '#8A2BE2', // Blue violet
'calm' => '#98FB98', // Pale green
'sedated' => '#708090', // Slate gray
];
$key = strtolower(trim($effect));
return isset($colors[$key]) ? $colors[$key] : '#6B7280'; // Default gray
}
/**
* Render effect chip HTML
*
* @param string $effect Effect name
* @param array $args Optional args: show_icon, size, class
* @return string HTML for effect chip
*/
function cannaiq_render_effect_chip($effect, $args = []) {
$defaults = [
'show_icon' => true,
'size' => 'medium',
'class' => '',
];
$args = wp_parse_args($args, $defaults);
$effect_name = ucfirst(strtolower(trim($effect)));
$color = cannaiq_get_effect_color($effect);
$size_class = 'cannaiq-effect-chip--' . esc_attr($args['size']);
$extra_class = esc_attr($args['class']);
$icon_html = '';
if ($args['show_icon']) {
$icon_html = cannaiq_get_effect_icon($effect, [
'size' => $args['size'] === 'small' ? 12 : ($args['size'] === 'large' ? 20 : 16),
'color' => $color,
]);
}
return sprintf(
'<span class="cannaiq-effect-chip %s %s" style="--effect-color: %s">%s<span class="cannaiq-effect-chip__label">%s</span></span>',
$size_class,
$extra_class,
esc_attr($color),
$icon_html,
esc_html($effect_name)
);
}
/**
* Render multiple effect chips
*
* @param array $effects Array of effect names
* @param array $args Optional args: limit, show_icon, size
* @return string HTML for all effect chips
*/
function cannaiq_render_effects($effects, $args = []) {
$defaults = [
'limit' => 3,
'show_icon' => true,
'size' => 'medium',
'class' => '',
];
$args = wp_parse_args($args, $defaults);
if (!is_array($effects)) {
return '';
}
$effects = array_slice($effects, 0, intval($args['limit']));
$chips = array_map(function($effect) use ($args) {
return cannaiq_render_effect_chip($effect, [
'show_icon' => $args['show_icon'],
'size' => $args['size'],
]);
}, $effects);
return sprintf(
'<div class="cannaiq-effects-container %s">%s</div>',
esc_attr($args['class']),
implode('', $chips)
);
}

View File

@@ -1,416 +0,0 @@
<?php
/**
* CannaiQ Loop Builder Integration
*
* Adds CannaiQ Products as a custom query source for Elementor's Loop Grid widget.
* Users can create Loop Item templates and use CannaiQ dynamic tags inside them.
*
* @package CannaIQ_Menus
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* CannaiQ Loop Builder Class
*/
class CannaiQ_Loop_Builder {
/**
* Instance
*/
private static $instance = null;
/**
* Current products for the loop
*/
private $products = [];
/**
* Current product index
*/
private $current_index = 0;
/**
* Get instance
*/
public static function instance() {
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
public function __construct() {
// Register custom query source
add_action('elementor/query/register', [$this, 'register_query_source']);
// Add CannaiQ to Loop Grid query options
add_filter('elementor/loop/query_control_options', [$this, 'add_query_options']);
// Handle the custom query
add_action('elementor/query/cannaiq_products', [$this, 'handle_query'], 10, 2);
// Filter loop data
add_filter('elementor/loop_builder/query_data', [$this, 'filter_query_data'], 10, 2);
// Set product context before rendering loop item
add_action('elementor/loop_builder/before_render_item', [$this, 'before_render_item'], 10, 2);
// Clear product context after rendering
add_action('elementor/loop_builder/after_render_item', [$this, 'after_render_item']);
// Register controls for CannaiQ query settings
add_action('elementor/element/loop-grid/section_query/before_section_end', [$this, 'add_query_controls'], 10, 2);
}
/**
* Register query source
*/
public function register_query_source($query_manager) {
// Register CannaiQ as a query source
if (method_exists($query_manager, 'register')) {
// For newer Elementor versions
}
}
/**
* Add CannaiQ to query options dropdown
* Shows the dispensary name tied to the API key
*/
public function add_query_options($options) {
$dispensary_name = get_option('cannaiq_dispensary_name', '');
if (!empty($dispensary_name)) {
$options['cannaiq_products'] = sprintf(__('%s Products', 'cannaiq-menus'), $dispensary_name);
$options['cannaiq_specials'] = sprintf(__('%s Specials', 'cannaiq-menus'), $dispensary_name);
} else {
$options['cannaiq_products'] = __('CannaiQ Products', 'cannaiq-menus');
$options['cannaiq_specials'] = __('CannaiQ Specials', 'cannaiq-menus');
}
return $options;
}
/**
* Add query controls to Loop Grid widget
*/
public function add_query_controls($widget, $args) {
// Controls apply to both products and specials sources
$cannaiq_sources = ['cannaiq_products', 'cannaiq_specials'];
$widget->add_control('cannaiq_store_id', [
'label' => __('CannaiQ Store ID', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => get_option('cannaiq_default_store_id', ''),
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
'description' => __('Enter your CannaiQ dispensary/store ID', 'cannaiq-menus'),
]);
$widget->add_control('cannaiq_category', [
'label' => __('Category', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'options' => [
'' => __('All Categories', 'cannaiq-menus'),
'Flower' => __('Flower', 'cannaiq-menus'),
'Vape' => __('Vape', 'cannaiq-menus'),
'Edibles' => __('Edibles', 'cannaiq-menus'),
'Concentrates' => __('Concentrates', 'cannaiq-menus'),
'Pre-Rolls' => __('Pre-Rolls', 'cannaiq-menus'),
'Tinctures' => __('Tinctures', 'cannaiq-menus'),
'Topicals' => __('Topicals', 'cannaiq-menus'),
'Accessories' => __('Accessories', 'cannaiq-menus'),
],
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
]);
$widget->add_control('cannaiq_brand', [
'label' => __('Brand', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => '',
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
'description' => __('Filter by brand name', 'cannaiq-menus'),
]);
$widget->add_control('cannaiq_strain_type', [
'label' => __('Strain Type', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'options' => [
'' => __('All Strains', 'cannaiq-menus'),
'sativa' => __('Sativa', 'cannaiq-menus'),
'indica' => __('Indica', 'cannaiq-menus'),
'hybrid' => __('Hybrid', 'cannaiq-menus'),
],
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
]);
// On Special toggle only for products (specials always has it on)
$widget->add_control('cannaiq_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' => '',
'condition' => [
'query_source' => 'cannaiq_products',
],
]);
$widget->add_control('cannaiq_in_stock_only', [
'label' => __('In Stock Only', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
]);
$widget->add_control('cannaiq_sort_by', [
'label' => __('Sort By', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'name',
'options' => [
'name' => __('Name', 'cannaiq-menus'),
'price' => __('Price', 'cannaiq-menus'),
'thc' => __('THC %', 'cannaiq-menus'),
'updated' => __('Recently Updated', 'cannaiq-menus'),
],
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
]);
$widget->add_control('cannaiq_sort_dir', [
'label' => __('Sort Direction', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'asc',
'options' => [
'asc' => __('Ascending', 'cannaiq-menus'),
'desc' => __('Descending', 'cannaiq-menus'),
],
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
]);
$widget->add_control('cannaiq_limit', [
'label' => __('Products Limit', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 12,
'min' => 1,
'max' => 100,
'conditions' => [
'relation' => 'or',
'terms' => [
['name' => 'query_source', 'value' => 'cannaiq_products'],
['name' => 'query_source', 'value' => 'cannaiq_specials'],
],
],
]);
}
/**
* Handle the CannaiQ products query
*/
public function handle_query($query, $widget) {
// This modifies the WP_Query but we're using external data
// We'll handle this in filter_query_data instead
}
/**
* Filter query data to inject CannaiQ products
*/
public function filter_query_data($query_data, $widget) {
$settings = $widget->get_settings_for_display();
// Check if this is a CannaiQ query (products or specials)
$query_source = $settings['query_source'] ?? '';
$is_cannaiq = in_array($query_source, ['cannaiq_products', 'cannaiq_specials'], true);
if (!$is_cannaiq) {
return $query_data;
}
// For specials source, force on_special parameter
$is_specials = ($query_source === 'cannaiq_specials');
// Fetch products from CannaiQ API
$products = $this->fetch_products($settings, $is_specials);
if (empty($products)) {
return $query_data;
}
// Store products for later use
$this->products = $products;
$this->current_index = 0;
// Create fake post objects for Elementor to iterate
$fake_posts = [];
foreach ($products as $index => $product) {
$fake_post = new stdClass();
$fake_post->ID = 'cannaiq_' . ($product['id'] ?? $index);
$fake_post->post_title = $product['name'] ?? '';
$fake_post->post_type = 'cannaiq_product';
$fake_post->cannaiq_product = $product;
$fake_post->cannaiq_index = $index;
$fake_posts[] = $fake_post;
}
// Override query data
$query_data['posts'] = $fake_posts;
$query_data['total'] = count($products);
return $query_data;
}
/**
* Fetch products from CannaiQ API
*
* @param array $settings Widget settings
* @param bool $is_specials Whether to force on_special filter (for Specials source)
*/
private function fetch_products($settings, $is_specials = false) {
$api_url = CANNAIQ_MENUS_API_URL;
$api_token = get_option('cannaiq_api_token');
// Build query params
$params = [
'limit' => $settings['cannaiq_limit'] ?? 12,
'sort_by' => $settings['cannaiq_sort_by'] ?? 'name',
'sort_dir' => $settings['cannaiq_sort_dir'] ?? 'asc',
];
if (!empty($settings['cannaiq_store_id'])) {
$params['dispensary_id'] = $settings['cannaiq_store_id'];
}
if (!empty($settings['cannaiq_category'])) {
$params['category'] = $settings['cannaiq_category'];
}
if (!empty($settings['cannaiq_brand'])) {
$params['brand'] = $settings['cannaiq_brand'];
}
if (!empty($settings['cannaiq_strain_type'])) {
$params['strain_type'] = $settings['cannaiq_strain_type'];
}
// Force on_special for Specials source, otherwise use toggle setting
if ($is_specials || $settings['cannaiq_on_special'] === 'yes') {
$params['on_special'] = 'true';
}
if ($settings['cannaiq_in_stock_only'] === 'yes') {
$params['in_stock_only'] = 'true';
}
// Make API request
$url = $api_url . '/products?' . http_build_query($params);
$args = [
'headers' => [
'Authorization' => 'Bearer ' . $api_token,
'Content-Type' => 'application/json',
],
'timeout' => 30,
];
$response = wp_remote_get($url, $args);
if (is_wp_error($response)) {
error_log('CannaiQ Loop Builder: API error - ' . $response->get_error_message());
return [];
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (!isset($data['products']) || !is_array($data['products'])) {
error_log('CannaiQ Loop Builder: Invalid API response');
return [];
}
return $data['products'];
}
/**
* Set product context before rendering each loop item
*/
public function before_render_item($post, $widget) {
// Check if this is a CannaiQ product
if (isset($post->cannaiq_product)) {
global $cannaiq_current_product;
$cannaiq_current_product = $post->cannaiq_product;
} elseif (isset($post->cannaiq_index) && isset($this->products[$post->cannaiq_index])) {
global $cannaiq_current_product;
$cannaiq_current_product = $this->products[$post->cannaiq_index];
}
}
/**
* Clear product context after rendering
*/
public function after_render_item() {
global $cannaiq_current_product;
$cannaiq_current_product = null;
}
}
/**
* Initialize Loop Builder integration
*/
function cannaiq_init_loop_builder() {
if (did_action('elementor/loaded')) {
CannaiQ_Loop_Builder::instance();
}
}
add_action('init', 'cannaiq_init_loop_builder');

View File

@@ -1,176 +0,0 @@
{
"title": "CannaiQ Horizontal Product Row",
"type": "page",
"version": "0.4",
"page_settings": [],
"content": [
{
"id": "cannaiq-horizontal-row",
"elType": "container",
"settings": {
"flex_direction": "row",
"content_width": "full",
"width": {"unit": "%", "size": 100},
"min_height": {"unit": "px", "size": 100},
"padding": {"unit": "px", "top": "16", "right": "20", "bottom": "16", "left": "20"},
"gap": {"unit": "px", "size": 16},
"align_items": "center",
"background_background": "classic",
"background_color": "#FFFFFF",
"border_border": "solid",
"border_width": {"unit": "px", "top": "0", "right": "0", "bottom": "1", "left": "0"},
"border_color": "#e5e7eb"
},
"elements": [
{
"id": "thumbnail-container",
"elType": "container",
"settings": {
"content_width": "initial",
"width": {"unit": "px", "size": 80},
"min_height": {"unit": "px", "size": 80},
"background_background": "classic",
"background_color": "#f9fafb",
"border_radius": {"unit": "px", "top": "8", "right": "8", "bottom": "8", "left": "8"},
"overflow": "hidden"
},
"elements": [
{
"id": "row-product-image",
"elType": "widget",
"widgetType": "image",
"settings": {
"image": {"url": "", "id": "", "dynamic": {"active": true, "settings": {"tag": "cannaiq-image"}}},
"image_size": "thumbnail",
"width": {"unit": "px", "size": 80},
"height": {"unit": "px", "size": 80},
"object-fit": "cover"
}
}
]
},
{
"id": "details-container",
"elType": "container",
"settings": {
"flex_direction": "column",
"content_width": "full",
"gap": {"unit": "px", "size": 4},
"flex_grow": "1"
},
"elements": [
{
"id": "row-product-name",
"elType": "widget",
"widgetType": "heading",
"settings": {
"title": "{{cannaiq-product-name}} | {{cannaiq-brand-name}}",
"header_size": "h4",
"title_color": "#1f2937",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 15},
"typography_font_weight": "600"
}
},
{
"id": "row-brand",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "{{cannaiq-brand-name}}",
"text_color": "#6b7280",
"typography_font_size": {"unit": "px", "size": 13}
}
},
{
"id": "row-thc",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "THC: {{cannaiq-thc}}",
"text_color": "#6b7280",
"typography_font_size": {"unit": "px", "size": 13}
}
},
{
"id": "row-deal-line",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "<span style=\"color:#22c55e;\">✓ {{cannaiq-price-sale}} | {{cannaiq-weight}} | {{cannaiq-brand-name}}</span>",
"typography_font_size": {"unit": "px", "size": 13}
}
}
]
},
{
"id": "price-container",
"elType": "container",
"settings": {
"flex_direction": "column",
"content_width": "initial",
"align_items": "flex-end",
"gap": {"unit": "px", "size": 4}
},
"elements": [
{
"id": "row-weight-pill",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "<span style=\"background:#f3f4f6;padding:4px 10px;border-radius:20px;font-size:12px;\">{{cannaiq-weight}}</span>"
}
},
{
"id": "row-price",
"elType": "widget",
"widgetType": "heading",
"settings": {
"title": "{{cannaiq-price-sale}}",
"header_size": "h3",
"title_color": "#1f2937",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 20},
"typography_font_weight": "700"
}
},
{
"id": "row-original-price",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "<span style=\"text-decoration:line-through;color:#9ca3af;\">{{cannaiq-price-original}}</span> <span style=\"color:#22c55e;font-size:12px;\">{{cannaiq-discount-percent}}</span>"
}
}
]
},
{
"id": "add-button-container",
"elType": "container",
"settings": {
"content_width": "initial"
},
"elements": [
{
"id": "row-add-button",
"elType": "widget",
"widgetType": "button",
"settings": {
"text": "+",
"link": {"url": "{{cannaiq-menu-url}}", "is_external": true},
"button_type": "default",
"background_color": "#22c55e",
"button_text_color": "#FFFFFF",
"border_radius": {"unit": "px", "top": "50", "right": "50", "bottom": "50", "left": "50"},
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 20},
"typography_font_weight": "700",
"button_padding": {"unit": "px", "top": "8", "right": "14", "bottom": "8", "left": "14"}
}
}
]
}
]
}
]
}

View File

@@ -1,189 +0,0 @@
{
"title": "CannaiQ Premium Product Card",
"type": "page",
"version": "0.4",
"page_settings": [],
"content": [
{
"id": "cannaiq-premium-card",
"elType": "container",
"settings": {
"flex_direction": "column",
"content_width": "full",
"width": {"unit": "px", "size": 280},
"min_height": {"unit": "px", "size": 420},
"padding": {"unit": "px", "top": "0", "right": "0", "bottom": "16", "left": "0"},
"background_background": "classic",
"background_color": "#FFFFFF",
"border_radius": {"unit": "px", "top": "12", "right": "12", "bottom": "12", "left": "12"},
"box_shadow_box_shadow_type": "yes",
"box_shadow_box_shadow": {"horizontal": 0, "vertical": 4, "blur": 20, "spread": 0, "color": "rgba(0,0,0,0.08)"},
"overflow": "hidden"
},
"elements": [
{
"id": "discount-ribbon",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "<span style=\"background:#ef4444;color:white;padding:4px 12px;font-weight:700;font-size:12px;position:absolute;top:0;left:0;border-bottom-right-radius:8px;z-index:10;\">{{cannaiq-discount-percent}}</span>",
"_position": "absolute",
"_element_width": "initial"
}
},
{
"id": "image-container",
"elType": "container",
"settings": {
"content_width": "full",
"min_height": {"unit": "px", "size": 200},
"background_background": "classic",
"background_color": "#f9fafb",
"position": "relative"
},
"elements": [
{
"id": "product-image",
"elType": "widget",
"widgetType": "image",
"settings": {
"image": {"url": "", "id": "", "dynamic": {"active": true, "settings": {"tag": "cannaiq-image"}}},
"image_size": "full",
"width": {"unit": "%", "size": 100},
"height": {"unit": "px", "size": 200},
"object-fit": "cover"
}
},
{
"id": "badges-row",
"elType": "container",
"settings": {
"flex_direction": "row",
"content_width": "full",
"gap": {"unit": "px", "size": 8},
"padding": {"unit": "px", "top": "8", "right": "8", "bottom": "8", "left": "8"},
"position": "absolute",
"_offset_y": {"unit": "px", "size": 0},
"_offset_x": {"unit": "px", "size": 0}
},
"elements": [
{
"id": "strain-badge",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "{{cannaiq-strain-badge}}",
"_element_width": "initial"
}
},
{
"id": "thc-badge",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "{{cannaiq-thc-badge}}",
"_element_width": "initial"
}
}
]
}
]
},
{
"id": "content-container",
"elType": "container",
"settings": {
"flex_direction": "column",
"content_width": "full",
"padding": {"unit": "px", "top": "16", "right": "16", "bottom": "16", "left": "16"},
"gap": {"unit": "px", "size": 8}
},
"elements": [
{
"id": "product-name",
"elType": "widget",
"widgetType": "heading",
"settings": {
"title": "{{cannaiq-product-name}}",
"header_size": "h3",
"title_color": "#1f2937",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 16},
"typography_font_weight": "700"
}
},
{
"id": "brand-name",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "by {{cannaiq-brand-name}}",
"text_color": "#6b7280",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 14}
}
},
{
"id": "effects",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "{{cannaiq-effects-chips}}",
"_element_width": "full"
}
},
{
"id": "price-row",
"elType": "container",
"settings": {
"flex_direction": "row",
"content_width": "full",
"align_items": "baseline",
"gap": {"unit": "px", "size": 8}
},
"elements": [
{
"id": "weight",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "{{cannaiq-weight}}",
"text_color": "#9ca3af",
"typography_font_size": {"unit": "px", "size": 14}
}
},
{
"id": "price",
"elType": "widget",
"widgetType": "text-editor",
"settings": {
"editor": "{{cannaiq-price-display}}",
"_element_width": "initial"
}
}
]
},
{
"id": "cart-button",
"elType": "widget",
"widgetType": "button",
"settings": {
"text": "ADD TO CART",
"link": {"url": "{{cannaiq-menu-url}}", "is_external": true},
"align": "stretch",
"button_type": "default",
"background_color": "#1f2937",
"button_text_color": "#FFFFFF",
"border_radius": {"unit": "px", "top": "6", "right": "6", "bottom": "6", "left": "6"},
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 12},
"typography_font_weight": "600",
"typography_letter_spacing": {"unit": "px", "size": 0.5}
}
}
]
}
]
}
]
}

View File

@@ -1,117 +0,0 @@
{
"title": "CannaiQ Promo Banner",
"type": "page",
"version": "0.4",
"page_settings": [],
"content": [
{
"id": "cannaiq-promo-banner",
"elType": "container",
"settings": {
"flex_direction": "row",
"content_width": "full",
"width": {"unit": "%", "size": 100},
"min_height": {"unit": "px", "size": 140},
"padding": {"unit": "px", "top": "24", "right": "32", "bottom": "24", "left": "32"},
"gap": {"unit": "px", "size": 24},
"justify_content": "space-between",
"align_items": "center",
"background_background": "classic",
"background_color": "#1a1a2e",
"border_radius": {"unit": "px", "top": "16", "right": "16", "bottom": "16", "left": "16"},
"overflow": "hidden"
},
"elements": [
{
"id": "text-content",
"elType": "container",
"settings": {
"flex_direction": "column",
"content_width": "full",
"gap": {"unit": "px", "size": 12},
"flex_grow": "1"
},
"elements": [
{
"id": "headline",
"elType": "widget",
"widgetType": "heading",
"settings": {
"title": "BOGO",
"header_size": "h2",
"title_color": "#FFFFFF",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 28},
"typography_font_weight": "900"
}
},
{
"id": "subheadline",
"elType": "widget",
"widgetType": "heading",
"settings": {
"title": "1G SPACE DUST",
"header_size": "h3",
"title_color": "#FFFFFF",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 22},
"typography_font_weight": "700"
}
},
{
"id": "product-line",
"elType": "widget",
"widgetType": "heading",
"settings": {
"title": "SPACE ROCKS",
"header_size": "h3",
"title_color": "#FFFFFF",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 22},
"typography_font_weight": "700"
}
},
{
"id": "cta-button",
"elType": "widget",
"widgetType": "button",
"settings": {
"text": "Shop Now",
"link": {"url": "#", "is_external": true},
"align": "left",
"button_type": "default",
"icon": {"value": "fas fa-chevron-circle-right", "library": "fa-solid"},
"icon_align": "left",
"background_color": "transparent",
"button_text_color": "#FFFFFF",
"typography_typography": "custom",
"typography_font_size": {"unit": "px", "size": 14},
"typography_font_weight": "500"
}
}
]
},
{
"id": "product-image-container",
"elType": "container",
"settings": {
"content_width": "initial",
"width": {"unit": "px", "size": 200}
},
"elements": [
{
"id": "promo-product-image",
"elType": "widget",
"widgetType": "image",
"settings": {
"image": {"url": "https://via.placeholder.com/200x150", "id": ""},
"image_size": "full",
"width": {"unit": "px", "size": 200}
}
}
]
}
]
}
]
}

View File

@@ -14,7 +14,7 @@ class CannaIQ_Menus_Brand_Grid_Widget extends \Elementor\Widget_Base {
} }
public function get_title() { public function get_title() {
return __('CannaiQ Brand Grid', 'cannaiq-menus'); return __('CannaIQ Brand Grid', 'cannaiq-menus');
} }
public function get_icon() { public function get_icon() {

View File

@@ -1,297 +0,0 @@
<?php
/**
* CannaiQ Horizontal Row Container Widget
*
* Empty row container - user adds their own content inside.
* Provides styling framework for horizontal product rows.
*/
if (!defined('ABSPATH')) exit;
class CannaiQ_Card_Container_Horizontal extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_card_container_horizontal';
}
public function get_title() {
return 'CannaiQ Horizontal Row';
}
public function get_icon() {
return 'eicon-post-list';
}
public function get_categories() {
return ['cannaiq-cards'];
}
public function get_keywords() {
return ['cannaiq', 'horizontal', 'row', 'list', 'product'];
}
protected function register_controls() {
// ===================
// LAYOUT SECTION
// ===================
$this->start_controls_section('section_layout', [
'label' => 'Layout',
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]);
$this->add_responsive_control('row_height', [
'label' => 'Row Height',
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => ['min' => 60, 'max' => 200],
],
'default' => ['unit' => 'px', 'size' => 100],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal' => 'min-height: {{SIZE}}{{UNIT}};',
],
]);
$this->add_responsive_control('content_padding', [
'label' => 'Content Padding',
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', 'em'],
'default' => [
'top' => '16',
'right' => '20',
'bottom' => '16',
'left' => '20',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]);
$this->add_responsive_control('content_gap', [
'label' => 'Content Gap',
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', 'em'],
'range' => [
'px' => ['min' => 0, 'max' => 40],
],
'default' => ['unit' => 'px', 'size' => 16],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal' => 'gap: {{SIZE}}{{UNIT}};',
],
]);
$this->add_control('content_justify', [
'label' => 'Horizontal Align',
'type' => \Elementor\Controls_Manager::CHOOSE,
'options' => [
'flex-start' => ['title' => 'Start', 'icon' => 'eicon-h-align-left'],
'center' => ['title' => 'Center', 'icon' => 'eicon-h-align-center'],
'flex-end' => ['title' => 'End', 'icon' => 'eicon-h-align-right'],
'space-between' => ['title' => 'Space Between', 'icon' => 'eicon-h-align-stretch'],
],
'default' => 'flex-start',
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal' => 'justify-content: {{VALUE}};',
],
]);
$this->add_control('content_align', [
'label' => 'Vertical Align',
'type' => \Elementor\Controls_Manager::CHOOSE,
'options' => [
'flex-start' => ['title' => 'Top', 'icon' => 'eicon-v-align-top'],
'center' => ['title' => 'Middle', 'icon' => 'eicon-v-align-middle'],
'flex-end' => ['title' => 'Bottom', 'icon' => 'eicon-v-align-bottom'],
'stretch' => ['title' => 'Stretch', 'icon' => 'eicon-v-align-stretch'],
],
'default' => 'center',
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal' => 'align-items: {{VALUE}};',
],
]);
$this->end_controls_section();
// ===================
// STYLE - ROW
// ===================
$this->start_controls_section('section_style_row', [
'label' => 'Row Style',
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]);
$this->add_group_control(\Elementor\Group_Control_Background::get_type(), [
'name' => 'row_background',
'label' => 'Background',
'types' => ['classic', 'gradient'],
'selector' => '{{WRAPPER}} .cannaiq-card-horizontal',
'fields_options' => [
'background' => ['default' => 'classic'],
'color' => ['default' => '#ffffff'],
],
]);
$this->add_group_control(\Elementor\Group_Control_Border::get_type(), [
'name' => 'row_border',
'label' => 'Border',
'selector' => '{{WRAPPER}} .cannaiq-card-horizontal',
'fields_options' => [
'border' => ['default' => 'solid'],
'width' => [
'default' => [
'top' => '0',
'right' => '0',
'bottom' => '1',
'left' => '0',
'unit' => 'px',
],
],
'color' => ['default' => '#e5e7eb'],
],
]);
$this->add_responsive_control('row_border_radius', [
'label' => 'Border Radius',
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px'],
'default' => [
'top' => '0',
'right' => '0',
'bottom' => '0',
'left' => '0',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]);
$this->add_group_control(\Elementor\Group_Control_Box_Shadow::get_type(), [
'name' => 'row_shadow',
'label' => 'Box Shadow',
'selector' => '{{WRAPPER}} .cannaiq-card-horizontal',
]);
$this->end_controls_section();
// ===================
// STYLE - HOVER
// ===================
$this->start_controls_section('section_style_hover', [
'label' => 'Hover Effects',
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]);
$this->add_control('hover_background', [
'label' => 'Hover Background',
'type' => \Elementor\Controls_Manager::COLOR,
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal:hover' => 'background-color: {{VALUE}};',
],
]);
$this->add_control('hover_transition', [
'label' => 'Transition Duration (ms)',
'type' => \Elementor\Controls_Manager::SLIDER,
'range' => [
'px' => ['min' => 100, 'max' => 500, 'step' => 50],
],
'default' => ['size' => 200],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-horizontal' => 'transition: background-color {{SIZE}}ms ease;',
],
]);
$this->end_controls_section();
// ===================
// DATA ATTRIBUTES
// ===================
$this->start_controls_section('section_data', [
'label' => 'Analytics Data',
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]);
$this->add_control('product_id', [
'label' => 'Product ID',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->add_control('product_name', [
'label' => 'Product Name',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->add_control('store_id', [
'label' => 'Store ID',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->add_control('category', [
'label' => 'Category',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Build data attributes for analytics
$data_attrs = '';
if (!empty($settings['product_id'])) {
$data_attrs .= ' data-product-id="' . esc_attr($settings['product_id']) . '"';
}
if (!empty($settings['product_name'])) {
$data_attrs .= ' data-product-name="' . esc_attr($settings['product_name']) . '"';
}
if (!empty($settings['store_id'])) {
$data_attrs .= ' data-store-id="' . esc_attr($settings['store_id']) . '"';
}
if (!empty($settings['category'])) {
$data_attrs .= ' data-category="' . esc_attr($settings['category']) . '"';
}
?>
<div class="cannaiq-card-horizontal"<?php echo $data_attrs; ?>>
</div>
<style>
.cannaiq-card-horizontal {
display: flex;
flex-direction: row;
position: relative;
box-sizing: border-box;
}
</style>
<?php
}
protected function content_template() {
?>
<#
var dataAttrs = '';
if (settings.product_id) dataAttrs += ' data-product-id="' + settings.product_id + '"';
if (settings.product_name) dataAttrs += ' data-product-name="' + settings.product_name + '"';
if (settings.store_id) dataAttrs += ' data-store-id="' + settings.store_id + '"';
if (settings.category) dataAttrs += ' data-category="' + settings.category + '"';
#>
<div class="cannaiq-card-horizontal"{{{ dataAttrs }}}>
<div class="cannaiq-card-placeholder" style="padding:20px;text-align:center;color:#999;border:2px dashed #ddd;border-radius:8px;flex:1;">
<p><strong>Horizontal Row Container</strong></p>
<p style="font-size:12px;">Add your content: Image | Name, Brand, Details | Price, Cart Button</p>
</div>
</div>
<?php
}
}

View File

@@ -1,330 +0,0 @@
<?php
/**
* CannaiQ Premium Card Container Widget
*
* Empty card container - user adds their own content inside.
* Provides styling framework for vertical product cards.
*/
if (!defined('ABSPATH')) exit;
class CannaiQ_Card_Container_Premium extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_card_container_premium';
}
public function get_title() {
return 'CannaiQ Premium Card';
}
public function get_icon() {
return 'eicon-single-post';
}
public function get_categories() {
return ['cannaiq-cards'];
}
public function get_keywords() {
return ['cannaiq', 'card', 'premium', 'product', 'container'];
}
protected function register_controls() {
// ===================
// LAYOUT SECTION
// ===================
$this->start_controls_section('section_layout', [
'label' => 'Layout',
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]);
$this->add_responsive_control('card_width', [
'label' => 'Card Width',
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', '%', 'vw'],
'range' => [
'px' => ['min' => 200, 'max' => 600],
'%' => ['min' => 20, 'max' => 100],
],
'default' => ['unit' => 'px', 'size' => 280],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-premium' => 'width: {{SIZE}}{{UNIT}};',
],
]);
$this->add_responsive_control('card_min_height', [
'label' => 'Min Height',
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', 'vh'],
'range' => [
'px' => ['min' => 200, 'max' => 800],
],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-premium' => 'min-height: {{SIZE}}{{UNIT}};',
],
]);
$this->add_responsive_control('content_padding', [
'label' => 'Content Padding',
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', 'em', '%'],
'default' => [
'top' => '16',
'right' => '16',
'bottom' => '16',
'left' => '16',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-premium' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]);
$this->add_responsive_control('content_gap', [
'label' => 'Content Gap',
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', 'em'],
'range' => [
'px' => ['min' => 0, 'max' => 40],
],
'default' => ['unit' => 'px', 'size' => 12],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-premium' => 'gap: {{SIZE}}{{UNIT}};',
],
]);
$this->add_control('content_align', [
'label' => 'Content Alignment',
'type' => \Elementor\Controls_Manager::CHOOSE,
'options' => [
'flex-start' => ['title' => 'Left', 'icon' => 'eicon-text-align-left'],
'center' => ['title' => 'Center', 'icon' => 'eicon-text-align-center'],
'flex-end' => ['title' => 'Right', 'icon' => 'eicon-text-align-right'],
],
'default' => 'flex-start',
'selectors' => [
'{{WRAPPER}} .cannaiq-card-premium' => 'align-items: {{VALUE}};',
],
]);
$this->end_controls_section();
// ===================
// STYLE - CARD
// ===================
$this->start_controls_section('section_style_card', [
'label' => 'Card Style',
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]);
$this->add_group_control(\Elementor\Group_Control_Background::get_type(), [
'name' => 'card_background',
'label' => 'Background',
'types' => ['classic', 'gradient'],
'selector' => '{{WRAPPER}} .cannaiq-card-premium',
'fields_options' => [
'background' => ['default' => 'classic'],
'color' => ['default' => '#ffffff'],
],
]);
$this->add_group_control(\Elementor\Group_Control_Border::get_type(), [
'name' => 'card_border',
'label' => 'Border',
'selector' => '{{WRAPPER}} .cannaiq-card-premium',
]);
$this->add_responsive_control('card_border_radius', [
'label' => 'Border Radius',
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', '%'],
'default' => [
'top' => '12',
'right' => '12',
'bottom' => '12',
'left' => '12',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-premium' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]);
$this->add_group_control(\Elementor\Group_Control_Box_Shadow::get_type(), [
'name' => 'card_shadow',
'label' => 'Box Shadow',
'selector' => '{{WRAPPER}} .cannaiq-card-premium',
'fields_options' => [
'box_shadow_type' => ['default' => 'yes'],
'box_shadow' => [
'default' => [
'horizontal' => 0,
'vertical' => 4,
'blur' => 20,
'spread' => 0,
'color' => 'rgba(0,0,0,0.08)',
],
],
],
]);
$this->end_controls_section();
// ===================
// STYLE - HOVER
// ===================
$this->start_controls_section('section_style_hover', [
'label' => 'Hover Effects',
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]);
$this->add_control('hover_animation', [
'label' => 'Hover Animation',
'type' => \Elementor\Controls_Manager::SELECT,
'options' => [
'none' => 'None',
'lift' => 'Lift Up',
'scale' => 'Scale Up',
'glow' => 'Glow',
],
'default' => 'lift',
]);
$this->add_control('hover_transition', [
'label' => 'Transition Duration (ms)',
'type' => \Elementor\Controls_Manager::SLIDER,
'range' => [
'px' => ['min' => 100, 'max' => 1000, 'step' => 50],
],
'default' => ['size' => 300],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-premium' => 'transition: all {{SIZE}}ms ease;',
],
]);
$this->add_group_control(\Elementor\Group_Control_Box_Shadow::get_type(), [
'name' => 'card_shadow_hover',
'label' => 'Hover Shadow',
'selector' => '{{WRAPPER}} .cannaiq-card-premium:hover',
]);
$this->end_controls_section();
// ===================
// DATA ATTRIBUTES
// ===================
$this->start_controls_section('section_data', [
'label' => 'Analytics Data',
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]);
$this->add_control('data_info', [
'type' => \Elementor\Controls_Manager::RAW_HTML,
'raw' => '<p style="color:#666;">These fields enable click analytics. Use dynamic tags to populate from Product Loop.</p>',
]);
$this->add_control('product_id', [
'label' => 'Product ID',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->add_control('product_name', [
'label' => 'Product Name',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->add_control('store_id', [
'label' => 'Store ID',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->add_control('category', [
'label' => 'Category',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Build data attributes for analytics
$data_attrs = '';
if (!empty($settings['product_id'])) {
$data_attrs .= ' data-product-id="' . esc_attr($settings['product_id']) . '"';
}
if (!empty($settings['product_name'])) {
$data_attrs .= ' data-product-name="' . esc_attr($settings['product_name']) . '"';
}
if (!empty($settings['store_id'])) {
$data_attrs .= ' data-store-id="' . esc_attr($settings['store_id']) . '"';
}
if (!empty($settings['category'])) {
$data_attrs .= ' data-category="' . esc_attr($settings['category']) . '"';
}
// Hover animation class
$hover_class = '';
if ($settings['hover_animation'] !== 'none') {
$hover_class = ' cannaiq-hover-' . $settings['hover_animation'];
}
?>
<div class="cannaiq-card-premium<?php echo $hover_class; ?>"<?php echo $data_attrs; ?>>
<?php
// Render nested content if using Elementor's content area
// Users will add widgets inside this container
?>
</div>
<style>
.cannaiq-card-premium {
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.cannaiq-hover-lift:hover {
transform: translateY(-8px);
}
.cannaiq-hover-scale:hover {
transform: scale(1.02);
}
.cannaiq-hover-glow:hover {
box-shadow: 0 0 30px rgba(34, 197, 94, 0.3);
}
</style>
<?php
}
protected function content_template() {
?>
<#
var hoverClass = settings.hover_animation !== 'none' ? ' cannaiq-hover-' + settings.hover_animation : '';
var dataAttrs = '';
if (settings.product_id) dataAttrs += ' data-product-id="' + settings.product_id + '"';
if (settings.product_name) dataAttrs += ' data-product-name="' + settings.product_name + '"';
if (settings.store_id) dataAttrs += ' data-store-id="' + settings.store_id + '"';
if (settings.category) dataAttrs += ' data-category="' + settings.category + '"';
#>
<div class="cannaiq-card-premium{{{ hoverClass }}}"{{{ dataAttrs }}}>
<div class="cannaiq-card-placeholder" style="padding:40px;text-align:center;color:#999;border:2px dashed #ddd;border-radius:8px;">
<p><strong>Premium Card Container</strong></p>
<p style="font-size:12px;">Add widgets inside this container:<br>
Image, Badges, Name, Price, Effects, Cart Button</p>
</div>
</div>
<?php
}
}

View File

@@ -1,331 +0,0 @@
<?php
/**
* CannaiQ Promo Banner Container Widget
*
* Empty banner container - user adds their own content inside.
* Provides styling framework for horizontal promotional banners.
*/
if (!defined('ABSPATH')) exit;
class CannaiQ_Card_Container_Promo extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_card_container_promo';
}
public function get_title() {
return 'CannaiQ Promo Banner';
}
public function get_icon() {
return 'eicon-banner';
}
public function get_categories() {
return ['cannaiq-cards'];
}
public function get_keywords() {
return ['cannaiq', 'promo', 'banner', 'deal', 'special'];
}
protected function register_controls() {
// ===================
// LAYOUT SECTION
// ===================
$this->start_controls_section('section_layout', [
'label' => 'Layout',
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]);
$this->add_responsive_control('banner_height', [
'label' => 'Banner Height',
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', 'vh'],
'range' => [
'px' => ['min' => 80, 'max' => 400],
'vh' => ['min' => 10, 'max' => 50],
],
'default' => ['unit' => 'px', 'size' => 140],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'min-height: {{SIZE}}{{UNIT}};',
],
]);
$this->add_responsive_control('content_padding', [
'label' => 'Content Padding',
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', 'em', '%'],
'default' => [
'top' => '20',
'right' => '30',
'bottom' => '20',
'left' => '30',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]);
$this->add_responsive_control('content_gap', [
'label' => 'Content Gap',
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', 'em'],
'range' => [
'px' => ['min' => 0, 'max' => 60],
],
'default' => ['unit' => 'px', 'size' => 20],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'gap: {{SIZE}}{{UNIT}};',
],
]);
$this->add_control('layout_direction', [
'label' => 'Layout Direction',
'type' => \Elementor\Controls_Manager::CHOOSE,
'options' => [
'row' => ['title' => 'Horizontal', 'icon' => 'eicon-arrow-right'],
'row-reverse' => ['title' => 'Horizontal Reverse', 'icon' => 'eicon-arrow-left'],
'column' => ['title' => 'Vertical', 'icon' => 'eicon-arrow-down'],
],
'default' => 'row',
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'flex-direction: {{VALUE}};',
],
]);
$this->add_control('content_justify', [
'label' => 'Horizontal Align',
'type' => \Elementor\Controls_Manager::CHOOSE,
'options' => [
'flex-start' => ['title' => 'Start', 'icon' => 'eicon-h-align-left'],
'center' => ['title' => 'Center', 'icon' => 'eicon-h-align-center'],
'flex-end' => ['title' => 'End', 'icon' => 'eicon-h-align-right'],
'space-between' => ['title' => 'Space Between', 'icon' => 'eicon-h-align-stretch'],
],
'default' => 'space-between',
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'justify-content: {{VALUE}};',
],
]);
$this->add_control('content_align', [
'label' => 'Vertical Align',
'type' => \Elementor\Controls_Manager::CHOOSE,
'options' => [
'flex-start' => ['title' => 'Top', 'icon' => 'eicon-v-align-top'],
'center' => ['title' => 'Middle', 'icon' => 'eicon-v-align-middle'],
'flex-end' => ['title' => 'Bottom', 'icon' => 'eicon-v-align-bottom'],
],
'default' => 'center',
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'align-items: {{VALUE}};',
],
]);
$this->end_controls_section();
// ===================
// STYLE - BANNER
// ===================
$this->start_controls_section('section_style_banner', [
'label' => 'Banner Style',
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]);
$this->add_group_control(\Elementor\Group_Control_Background::get_type(), [
'name' => 'banner_background',
'label' => 'Background',
'types' => ['classic', 'gradient'],
'selector' => '{{WRAPPER}} .cannaiq-card-promo',
'fields_options' => [
'background' => ['default' => 'classic'],
'color' => ['default' => '#1a1a2e'],
],
]);
$this->add_control('background_overlay', [
'label' => 'Background Overlay',
'type' => \Elementor\Controls_Manager::COLOR,
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo::before' => 'background-color: {{VALUE}};',
],
]);
$this->add_control('overlay_opacity', [
'label' => 'Overlay Opacity',
'type' => \Elementor\Controls_Manager::SLIDER,
'range' => [
'px' => ['min' => 0, 'max' => 1, 'step' => 0.1],
],
'default' => ['size' => 0],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo::before' => 'opacity: {{SIZE}};',
],
]);
$this->add_group_control(\Elementor\Group_Control_Border::get_type(), [
'name' => 'banner_border',
'label' => 'Border',
'selector' => '{{WRAPPER}} .cannaiq-card-promo',
]);
$this->add_responsive_control('banner_border_radius', [
'label' => 'Border Radius',
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', '%'],
'default' => [
'top' => '16',
'right' => '16',
'bottom' => '16',
'left' => '16',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]);
$this->add_group_control(\Elementor\Group_Control_Box_Shadow::get_type(), [
'name' => 'banner_shadow',
'label' => 'Box Shadow',
'selector' => '{{WRAPPER}} .cannaiq-card-promo',
]);
$this->end_controls_section();
// ===================
// STYLE - HOVER
// ===================
$this->start_controls_section('section_style_hover', [
'label' => 'Hover Effects',
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]);
$this->add_control('hover_animation', [
'label' => 'Hover Animation',
'type' => \Elementor\Controls_Manager::SELECT,
'options' => [
'none' => 'None',
'lift' => 'Lift Up',
'scale' => 'Scale Up',
'brighten' => 'Brighten',
],
'default' => 'none',
]);
$this->add_control('hover_transition', [
'label' => 'Transition Duration (ms)',
'type' => \Elementor\Controls_Manager::SLIDER,
'range' => [
'px' => ['min' => 100, 'max' => 1000, 'step' => 50],
],
'default' => ['size' => 300],
'selectors' => [
'{{WRAPPER}} .cannaiq-card-promo' => 'transition: all {{SIZE}}ms ease;',
],
]);
$this->end_controls_section();
// ===================
// DATA ATTRIBUTES
// ===================
$this->start_controls_section('section_data', [
'label' => 'Analytics Data',
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]);
$this->add_control('store_id', [
'label' => 'Store ID',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'Use dynamic tag',
]);
$this->add_control('promo_name', [
'label' => 'Promo Name',
'type' => \Elementor\Controls_Manager::TEXT,
'dynamic' => ['active' => true],
'placeholder' => 'For analytics tracking',
]);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Build data attributes for analytics
$data_attrs = ' data-cannaiq-track="promo_view"';
if (!empty($settings['store_id'])) {
$data_attrs .= ' data-store-id="' . esc_attr($settings['store_id']) . '"';
}
if (!empty($settings['promo_name'])) {
$data_attrs .= ' data-promo-name="' . esc_attr($settings['promo_name']) . '"';
}
// Hover animation class
$hover_class = '';
if ($settings['hover_animation'] !== 'none') {
$hover_class = ' cannaiq-hover-' . $settings['hover_animation'];
}
?>
<div class="cannaiq-card-promo<?php echo $hover_class; ?>"<?php echo $data_attrs; ?>>
</div>
<style>
.cannaiq-card-promo {
display: flex;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.cannaiq-card-promo::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 0;
}
.cannaiq-card-promo > * {
position: relative;
z-index: 1;
}
.cannaiq-hover-lift:hover {
transform: translateY(-4px);
}
.cannaiq-hover-scale:hover {
transform: scale(1.01);
}
.cannaiq-hover-brighten:hover {
filter: brightness(1.1);
}
</style>
<?php
}
protected function content_template() {
?>
<#
var hoverClass = settings.hover_animation !== 'none' ? ' cannaiq-hover-' + settings.hover_animation : '';
var dataAttrs = ' data-cannaiq-track="promo_view"';
if (settings.store_id) dataAttrs += ' data-store-id="' + settings.store_id + '"';
if (settings.promo_name) dataAttrs += ' data-promo-name="' + settings.promo_name + '"';
#>
<div class="cannaiq-card-promo{{{ hoverClass }}}"{{{ dataAttrs }}}>
<div class="cannaiq-card-placeholder" style="padding:30px;text-align:center;color:#999;border:2px dashed #555;border-radius:8px;flex:1;">
<p><strong>Promo Banner Container</strong></p>
<p style="font-size:12px;">Add your promo content:<br>
Headline, Description, CTA Button, Product Image</p>
</div>
</div>
<?php
}
}

View File

@@ -1,252 +0,0 @@
<?php
/**
* Elementor Category Card Widget
* Image-based category card display
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Card_Category_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_card_category';
}
public function get_title() {
return __('CannaiQ Category Card', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-image-box';
}
public function get_categories() {
return ['cannaiq-templates'];
}
public function get_keywords() {
return ['cannaiq', 'category', 'card', 'image'];
}
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(
'category_name',
[
'label' => __('Category Name', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'Flower',
'placeholder' => __('Category name...', 'cannaiq-menus'),
]
);
$this->add_control(
'category_image',
[
'label' => __('Category Image', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::MEDIA,
'default' => [
'url' => '',
],
]
);
$this->add_control(
'link_url',
[
'label' => __('Link URL', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::URL,
'placeholder' => __('/products?category=flower', 'cannaiq-menus'),
'default' => [
'url' => '#',
],
]
);
$this->add_control(
'show_product_count',
[
'label' => __('Show Product Count', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'no',
]
);
$this->add_control(
'product_count',
[
'label' => __('Product Count', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 0,
'condition' => [
'show_product_count' => 'yes',
],
]
);
$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_size',
[
'label' => __('Card Size', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'medium',
'options' => [
'small' => __('Small (120px)', 'cannaiq-menus'),
'medium' => __('Medium (160px)', 'cannaiq-menus'),
'large' => __('Large (200px)', 'cannaiq-menus'),
],
]
);
$this->add_control(
'card_background',
[
'label' => __('Card Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
]
);
$this->add_control(
'border_color',
[
'label' => __('Border Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#e5e7eb',
]
);
$this->add_control(
'hover_border_color',
[
'label' => __('Hover Border Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#22c55e',
]
);
$this->add_control(
'text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1f2937',
]
);
$this->add_control(
'border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 30,
],
],
'default' => [
'size' => 12,
],
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$sizes = [
'small' => '120px',
'medium' => '160px',
'large' => '200px',
];
$size = $sizes[$settings['card_size']] ?? '160px';
$bg_color = $settings['card_background'];
$border_color = $settings['border_color'];
$hover_border = $settings['hover_border_color'];
$text_color = $settings['text_color'];
$radius = $settings['border_radius']['size'] . 'px';
$url = $settings['link_url']['url'] ?? '#';
$target = !empty($settings['link_url']['is_external']) ? '_blank' : '_self';
$widget_id = $this->get_id();
?>
<style>
#cannaiq-cat-<?php echo esc_attr($widget_id); ?>:hover {
border-color: <?php echo esc_attr($hover_border); ?> !important;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
</style>
<a href="<?php echo esc_url($url); ?>"
target="<?php echo esc_attr($target); ?>"
id="cannaiq-cat-<?php echo esc_attr($widget_id); ?>"
class="cannaiq-category-card"
style="
display: block;
width: <?php echo esc_attr($size); ?>;
background: <?php echo esc_attr($bg_color); ?>;
border: 2px solid <?php echo esc_attr($border_color); ?>;
border-radius: <?php echo esc_attr($radius); ?>;
padding: 16px;
text-align: center;
text-decoration: none;
transition: all 0.2s ease;
">
<div class="cannaiq-cat-name" style="
font-weight: 600;
font-size: 16px;
color: <?php echo esc_attr($text_color); ?>;
margin-bottom: 12px;
">
<?php echo esc_html($settings['category_name']); ?>
<?php if ($settings['show_product_count'] === 'yes' && $settings['product_count'] > 0): ?>
<span style="font-weight: 400; color: #6b7280; font-size: 14px;">
(<?php echo esc_html($settings['product_count']); ?>)
</span>
<?php endif; ?>
</div>
<?php if (!empty($settings['category_image']['url'])): ?>
<div class="cannaiq-cat-image">
<img src="<?php echo esc_url($settings['category_image']['url']); ?>"
alt="<?php echo esc_attr($settings['category_name']); ?>"
style="
max-width: 100%;
height: auto;
max-height: 80px;
object-fit: contain;
" />
</div>
<?php endif; ?>
</a>
<?php
}
}

View File

@@ -1,405 +0,0 @@
<?php
/**
* Elementor Compact Product Card Widget
* Smaller vertical card for dense grids
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Card_Compact_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_card_compact';
}
public function get_title() {
return __('CannaiQ Compact Card', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-posts-grid';
}
public function get_categories() {
return ['cannaiq-templates'];
}
public function get_keywords() {
return ['cannaiq', 'product', 'compact', 'card', 'small'];
}
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' => 12,
'min' => 1,
'max' => 50,
]
);
$this->add_control(
'columns',
[
'label' => __('Columns', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '4',
'options' => [
'3' => __('3 Columns', 'cannaiq-menus'),
'4' => __('4 Columns', 'cannaiq-menus'),
'5' => __('5 Columns', 'cannaiq-menus'),
'6' => __('6 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(),
]
);
$this->add_control(
'specials_only',
[
'label' => __('Specials Only', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'no',
]
);
$this->end_controls_section();
// Display Options
$this->start_controls_section(
'display_section',
[
'label' => __('Display Options', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'show_brand',
[
'label' => __('Show Brand', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_thc_cbd',
[
'label' => __('Show THC/CBD', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_discount_badge',
[
'label' => __('Show Discount Badge', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_original_price',
[
'label' => __('Show Original Price', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_cart_button',
[
'label' => __('Show Add to Cart', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$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',
]
);
$this->add_control(
'border_color',
[
'label' => __('Border Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#e5e7eb',
]
);
$this->add_control(
'discount_badge_color',
[
'label' => __('Discount Badge Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#fbbf24',
]
);
$this->add_control(
'button_color',
[
'label' => __('Button Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f97316',
]
);
$this->add_control(
'border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 20,
],
],
'default' => [
'size' => 8,
],
]
);
$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();
if ($settings['specials_only'] === 'yes') {
$products = $plugin->fetch_specials($args);
} else {
$products = $plugin->fetch_products($args);
}
if (!$products) {
echo '<p>' . __('No products found.', 'cannaiq-menus') . '</p>';
return;
}
$columns = $settings['columns'];
$card_bg = $settings['card_background'];
$border_color = $settings['border_color'];
$discount_color = $settings['discount_badge_color'];
$btn_color = $settings['button_color'];
$radius = $settings['border_radius']['size'] . 'px';
$col_widths = [
'3' => '33.333%',
'4' => '25%',
'5' => '20%',
'6' => '16.666%',
];
$col_width = $col_widths[$columns] ?? '25%';
?>
<div class="cannaiq-compact-grid" style="
display: flex;
flex-wrap: wrap;
gap: 16px;
margin: -8px;
">
<?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'] ?? $product['price_rec'] ?? 0;
$sale_price = $product['sale_price'] ?? $product['price_rec_special'] ?? $regular_price;
$has_discount = $regular_price > 0 && $sale_price < $regular_price;
$discount_percent = $has_discount ? round((($regular_price - $sale_price) / $regular_price) * 100) : 0;
$brand = $product['brand'] ?? '';
$thc = $product['thc_percentage'] ?? '';
$cbd = $product['cbd_percentage'] ?? '';
?>
<div class="cannaiq-compact-card" style="
width: calc(<?php echo esc_attr($col_width); ?> - 16px);
min-width: 140px;
background: <?php echo esc_attr($card_bg); ?>;
border: 1px solid <?php echo esc_attr($border_color); ?>;
border-radius: <?php echo esc_attr($radius); ?>;
padding: 12px;
text-align: center;
">
<?php if (!empty($image_url)): ?>
<div class="cannaiq-cc-image" style="
width: 100%;
aspect-ratio: 1;
margin-bottom: 10px;
position: relative;
">
<img src="<?php echo esc_url($image_url); ?>"
alt="<?php echo esc_attr($product['name']); ?>"
style="
width: 100%;
height: 100%;
object-fit: contain;
" />
<div style="
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: #9ca3af;
background: rgba(255,255,255,0.9);
padding: 2px 6px;
border-radius: 4px;
">Stock photo. Actual product may vary.</div>
</div>
<?php endif; ?>
<div class="cannaiq-cc-name" style="
font-weight: 600;
font-size: 13px;
line-height: 1.3;
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
">
<?php echo esc_html($product['name']); ?>
</div>
<?php if ($settings['show_brand'] === 'yes' && !empty($brand)): ?>
<div class="cannaiq-cc-brand" style="
font-size: 12px;
color: #6b7280;
margin-bottom: 6px;
">
<?php echo esc_html($brand); ?>
</div>
<?php endif; ?>
<?php if ($settings['show_thc_cbd'] === 'yes' && (!empty($thc) || !empty($cbd))): ?>
<div class="cannaiq-cc-potency" style="
font-size: 11px;
color: #6b7280;
margin-bottom: 8px;
">
<?php if (!empty($thc)): ?>THC: <?php echo esc_html($thc); ?>%<?php endif; ?>
<?php if (!empty($thc) && !empty($cbd)): ?> · <?php endif; ?>
<?php if (!empty($cbd)): ?>CBD: <?php echo esc_html($cbd); ?>%<?php endif; ?>
</div>
<?php endif; ?>
<div class="cannaiq-cc-price" style="margin-bottom: 10px;">
<?php if ($settings['show_original_price'] === 'yes' && $has_discount): ?>
<div style="
text-decoration: line-through;
color: #9ca3af;
font-size: 12px;
">$<?php echo esc_html(number_format($regular_price, 2)); ?></div>
<?php endif; ?>
<div style="display: flex; align-items: center; justify-content: center; gap: 6px;">
<span style="font-size: 18px; font-weight: 700; color: #16a34a;">
$<?php echo esc_html(number_format($sale_price, 2)); ?>
</span>
<?php if ($settings['show_discount_badge'] === 'yes' && $has_discount): ?>
<span style="
background: <?php echo esc_attr($discount_color); ?>;
color: #1f2937;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
"><?php echo esc_html($discount_percent); ?>% off</span>
<?php endif; ?>
</div>
</div>
<?php if ($settings['show_cart_button'] === 'yes'): ?>
<a href="<?php echo esc_url($product_url); ?>"
target="_blank"
class="cannaiq-cc-button"
style="
display: block;
background: <?php echo esc_attr($btn_color); ?>;
color: white;
padding: 10px 16px;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
font-size: 13px;
transition: opacity 0.2s;
">ADD TO CART</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php
}
}

View File

@@ -1,368 +0,0 @@
<?php
/**
* Elementor Horizontal Product Row Widget
* Wide format product display for lists
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Card_Horizontal_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_card_horizontal';
}
public function get_title() {
return __('CannaiQ Horizontal Product Row', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-post-list';
}
public function get_categories() {
return ['cannaiq-templates'];
}
public function get_keywords() {
return ['cannaiq', 'product', 'horizontal', 'row', 'list'];
}
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' => 10,
'min' => 1,
'max' => 50,
]
);
$this->add_control(
'category',
[
'label' => __('Category', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'options' => CannaIQ_Menus_Plugin::instance()->get_category_options(),
]
);
$this->add_control(
'specials_only',
[
'label' => __('Specials Only', '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();
// Display Options
$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,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_brand',
[
'label' => __('Show Brand', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_thc',
[
'label' => __('Show THC', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_weight',
[
'label' => __('Show Weight', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_special_tag',
[
'label' => __('Show Special Tag', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_discount_badge',
[
'label' => __('Show Discount Badge', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->add_control(
'show_add_button',
[
'label' => __('Show Add Button', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'return_value' => 'yes',
'default' => 'yes',
]
);
$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(
'row_background',
[
'label' => __('Row Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
]
);
$this->add_control(
'border_color',
[
'label' => __('Border Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#e5e7eb',
]
);
$this->add_control(
'special_tag_color',
[
'label' => __('Special Tag Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#22c55e',
]
);
$this->add_control(
'discount_badge_color',
[
'label' => __('Discount Badge Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f97316',
]
);
$this->add_control(
'add_button_color',
[
'label' => __('Add Button Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#22c55e',
]
);
$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();
if ($settings['specials_only'] === 'yes') {
$products = $plugin->fetch_specials($args);
} else {
$products = $plugin->fetch_products($args);
}
if (!$products) {
echo '<p>' . __('No products found.', 'cannaiq-menus') . '</p>';
return;
}
$row_bg = $settings['row_background'];
$border_color = $settings['border_color'];
$special_color = $settings['special_tag_color'];
$discount_color = $settings['discount_badge_color'];
$btn_color = $settings['add_button_color'];
?>
<div class="cannaiq-horizontal-list">
<?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'] ?? $product['price_rec'] ?? 0;
$sale_price = $product['sale_price'] ?? $product['price_rec_special'] ?? $regular_price;
$has_discount = $regular_price > 0 && $sale_price < $regular_price;
$discount_percent = $has_discount ? round((($regular_price - $sale_price) / $regular_price) * 100) : 0;
$brand = $product['brand'] ?? '';
$thc = $product['thc_percentage'] ?? '';
$weight = $product['weight'] ?? $product['subcategory'] ?? '';
$special_name = $product['special_name'] ?? '';
?>
<div class="cannaiq-horizontal-row" style="
background: <?php echo esc_attr($row_bg); ?>;
border: 1px solid <?php echo esc_attr($border_color); ?>;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 16px;
">
<?php if ($settings['show_image'] === 'yes' && !empty($image_url)): ?>
<div class="cannaiq-hr-image" style="flex-shrink: 0; width: 60px; height: 60px;">
<img src="<?php echo esc_url($image_url); ?>"
alt="<?php echo esc_attr($product['name']); ?>"
style="width: 100%; height: 100%; object-fit: contain; border-radius: 4px;" />
</div>
<?php endif; ?>
<div class="cannaiq-hr-info" style="flex: 1; min-width: 0;">
<div class="cannaiq-hr-name" style="font-weight: 600; font-size: 15px; margin-bottom: 4px;">
<?php echo esc_html($product['name']); ?>
</div>
<?php if ($settings['show_brand'] === 'yes' && !empty($brand)): ?>
<div class="cannaiq-hr-brand" style="color: #6b7280; font-size: 13px; margin-bottom: 4px;">
<?php echo esc_html($brand); ?>
</div>
<?php endif; ?>
<div class="cannaiq-hr-meta" style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center; font-size: 13px;">
<?php if ($settings['show_thc'] === 'yes' && !empty($thc)): ?>
<span style="color: #6b7280;">THC: <?php echo esc_html($thc); ?>%</span>
<?php endif; ?>
<?php if ($settings['show_special_tag'] === 'yes' && !empty($special_name)): ?>
<span style="color: <?php echo esc_attr($special_color); ?>; font-weight: 500;">
● <?php echo esc_html($special_name); ?>
</span>
<?php endif; ?>
</div>
</div>
<div class="cannaiq-hr-price" style="text-align: right; flex-shrink: 0;">
<?php if ($settings['show_weight'] === 'yes' && !empty($weight)): ?>
<div style="font-size: 12px; color: #6b7280; margin-bottom: 4px;">
<?php echo esc_html($weight); ?>
</div>
<?php endif; ?>
<div style="font-size: 18px; font-weight: 700;">
$<?php echo esc_html(number_format($sale_price, 2)); ?>
</div>
<?php if ($has_discount): ?>
<div style="display: flex; align-items: center; gap: 6px; justify-content: flex-end;">
<span style="text-decoration: line-through; color: #9ca3af; font-size: 13px;">
$<?php echo esc_html(number_format($regular_price, 2)); ?>
</span>
<?php if ($settings['show_discount_badge'] === 'yes'): ?>
<span style="
background: <?php echo esc_attr($discount_color); ?>;
color: white;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
"><?php echo esc_html($discount_percent); ?>% off</span>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php if ($settings['show_add_button'] === 'yes'): ?>
<a href="<?php echo esc_url($product_url); ?>"
target="_blank"
class="cannaiq-hr-add-btn"
style="
flex-shrink: 0;
width: 36px;
height: 36px;
background: <?php echo esc_attr($btn_color); ?>;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
font-size: 20px;
font-weight: bold;
transition: opacity 0.2s;
">+</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php
}
}

View File

@@ -1,510 +0,0 @@
<?php
/**
* CannaIQ Premium Card Template Widget
*
* Pre-built product card template showcasing all modular components.
* Includes: discount ribbon, product image with overlays, name, brand,
* effects, price block, and cart button.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
// Ensure effects icons are loaded
require_once dirname(__DIR__) . '/includes/effects-icons.php';
class CannaIQ_Premium_Card_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_premium_card';
}
public function get_title() {
return __('Premium Product Card', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-single-product';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['product', 'card', 'premium', 'template', 'cannaiq'];
}
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(
'content_note',
[
'type' => \Elementor\Controls_Manager::RAW_HTML,
'raw' => __('This card uses the current product context from Product Loop or Product Grid. Place it inside a CannaiQ Product Loop widget.', 'cannaiq-menus'),
'content_classes' => 'elementor-panel-alert elementor-panel-alert-info',
]
);
$this->end_controls_section();
// Components Section
$this->start_controls_section(
'components_section',
[
'label' => __('Components', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'show_discount',
[
'label' => __('Show Discount Ribbon', '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_image',
[
'label' => __('Show Product 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_strain_badge',
[
'label' => __('Show Strain 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_thc_badge',
[
'label' => __('Show THC 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_name',
[
'label' => __('Show Product Name', '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_brand',
[
'label' => __('Show Brand', '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_effects',
[
'label' => __('Show Effects', '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(
'effects_limit',
[
'label' => __('Effects Limit', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 3,
'min' => 1,
'max' => 5,
'condition' => [
'show_effects' => 'yes',
],
]
);
$this->add_control(
'show_price',
[
'label' => __('Show 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_weight',
[
'label' => __('Show Weight', '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_cart_button',
[
'label' => __('Show Cart Button', '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(
'button_text',
[
'label' => __('Button Text', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'ADD TO CART',
'condition' => [
'show_cart_button' => 'yes',
],
]
);
$this->end_controls_section();
// Style Section
$this->start_controls_section(
'card_style_section',
[
'label' => __('Card Style', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_control(
'card_background',
[
'label' => __('Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-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' => 30,
],
],
'default' => [
'size' => 12,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-card' => 'border-radius: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_control(
'card_padding',
[
'label' => __('Padding', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 40,
],
],
'default' => [
'size' => 16,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-card__body' => 'padding: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Box_Shadow::get_type(),
[
'name' => 'card_shadow',
'selector' => '{{WRAPPER}} .cannaiq-product-card',
]
);
$this->end_controls_section();
// Typography Section
$this->start_controls_section(
'typography_section',
[
'label' => __('Typography', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'title_typography',
'label' => __('Title', 'cannaiq-menus'),
'selector' => '{{WRAPPER}} .cannaiq-product-card__title',
]
);
$this->add_control(
'title_color',
[
'label' => __('Title Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1f2937',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-card__title' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'brand_color',
[
'label' => __('Brand Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#6b7280',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-card__brand' => 'color: {{VALUE}};',
],
]
);
$this->end_controls_section();
// Button Style Section
$this->start_controls_section(
'button_style_section',
[
'label' => __('Button Style', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
'condition' => [
'show_cart_button' => 'yes',
],
]
);
$this->add_control(
'button_background',
[
'label' => __('Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1f2937',
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button' => 'background-color: {{VALUE}}; border-color: {{VALUE}};',
],
]
);
$this->add_control(
'button_text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'button_hover_background',
[
'label' => __('Hover Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#374151',
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button:hover' => 'background-color: {{VALUE}}; border-color: {{VALUE}};',
],
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
global $cannaiq_current_product;
$product = $cannaiq_current_product ?? [];
if (empty($product)) {
echo '<p>' . __('No product context available. Place this widget inside a Product Loop.', 'cannaiq-menus') . '</p>';
return;
}
// Extract product data
$name = $product['Name'] ?? $product['name'] ?? '';
$brand = $product['brand']['name'] ?? $product['brandName'] ?? $product['brand'] ?? '';
$image_url = $product['Image'] ?? $product['images'][0]['url'] ?? $product['image_url'] ?? '';
$strain_type = strtolower($product['strainType'] ?? $product['strain_type'] ?? '');
$thc = $product['THCContent']['range'][0] ?? $product['THC'] ?? $product['thc_percentage'] ?? null;
$weight = $product['Options'][0] ?? $product['rawOptions'][0] ?? $product['weight'] ?? '';
$menu_url = $product['menuUrl'] ?? $product['menu_url'] ?? $product['productUrl'] ?? '#';
// Price
$original_price = $product['Prices'][0] ?? $product['regular_price'] ?? null;
$sale_price = $product['specialPrice'] ?? $product['sale_price'] ?? null;
$is_on_sale = $sale_price && $sale_price > 0 && $sale_price < $original_price;
$discount_percent = 0;
if ($is_on_sale && $original_price > 0) {
$discount_percent = round((($original_price - $sale_price) / $original_price) * 100);
}
// Effects
$effects = $product['effects'] ?? [];
if (!empty($effects) && !isset($effects[0])) {
arsort($effects);
$effects = array_keys($effects);
}
$effects_limit = intval($settings['effects_limit']) ?: 3;
$effects = array_slice($effects, 0, $effects_limit);
// Strain colors
$strain_colors = [
'sativa' => '#22c55e',
'indica' => '#8b5cf6',
'hybrid' => '#f97316',
];
?>
<div class="cannaiq-product-card">
<!-- Image Section -->
<?php if ($settings['show_image'] === 'yes'): ?>
<div class="cannaiq-product-card__image">
<div class="cannaiq-product-image" style="aspect-ratio: 1;">
<?php if (!empty($image_url)): ?>
<img src="<?php echo esc_url($image_url); ?>" alt="<?php echo esc_attr($name); ?>" />
<?php endif; ?>
<!-- Discount Ribbon -->
<?php if ($settings['show_discount'] === 'yes' && $discount_percent > 0): ?>
<div class="cannaiq-product-image__overlay cannaiq-product-image__overlay--top-left">
<span class="cannaiq-discount-ribbon cannaiq-discount-ribbon--ribbon"><?php echo esc_html($discount_percent); ?>% OFF</span>
</div>
<?php endif; ?>
<!-- Bottom badges -->
<?php if (($settings['show_strain_badge'] === 'yes' && !empty($strain_type)) || ($settings['show_thc_badge'] === 'yes' && $thc > 0)): ?>
<div class="cannaiq-product-image__overlay cannaiq-product-image__overlay--bottom-left">
<div class="cannaiq-product-image__badges">
<?php if ($settings['show_strain_badge'] === 'yes' && !empty($strain_type) && in_array($strain_type, ['sativa', 'indica', 'hybrid'])): ?>
<span class="cannaiq-strain-badge cannaiq-strain-badge--pill" style="background-color: <?php echo esc_attr($strain_colors[$strain_type]); ?>; color: white;"><?php echo esc_html(strtoupper($strain_type)); ?></span>
<?php endif; ?>
<?php if ($settings['show_thc_badge'] === 'yes' && $thc > 0): ?>
<span class="cannaiq-potency-badge cannaiq-potency-badge--pill" style="background-color: #1f2937; color: white;"><?php echo esc_html(number_format((float)$thc, 1)); ?>% THC</span>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Body Section -->
<div class="cannaiq-product-card__body">
<?php if ($settings['show_name'] === 'yes' && !empty($name)): ?>
<h3 class="cannaiq-product-card__title"><?php echo esc_html($name); ?></h3>
<?php endif; ?>
<?php if ($settings['show_brand'] === 'yes' && !empty($brand)): ?>
<p class="cannaiq-product-card__brand">by <?php echo esc_html($brand); ?></p>
<?php endif; ?>
<?php if ($settings['show_effects'] === 'yes' && !empty($effects)): ?>
<div style="margin: 12px 0;">
<?php echo cannaiq_render_effects($effects, ['limit' => $effects_limit, 'show_icon' => true, 'size' => 'small']); ?>
</div>
<?php endif; ?>
<!-- Footer -->
<div class="cannaiq-product-card__footer">
<?php if ($settings['show_price'] === 'yes' && $original_price > 0): ?>
<div class="cannaiq-price-block" style="margin-bottom: 12px;">
<?php if ($settings['show_weight'] === 'yes' && !empty($weight)): ?>
<span class="cannaiq-price-block__weight"><?php echo esc_html($weight); ?></span>
<?php endif; ?>
<?php if ($is_on_sale): ?>
<span class="cannaiq-price-block__original">$<?php echo esc_html(number_format((float)$original_price, 2)); ?></span>
<span class="cannaiq-price-block__sale">$<?php echo esc_html(number_format((float)$sale_price, 2)); ?></span>
<?php else: ?>
<span class="cannaiq-price-block__regular">$<?php echo esc_html(number_format((float)$original_price, 2)); ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($settings['show_cart_button'] === 'yes'): ?>
<a href="<?php echo esc_url($menu_url); ?>" class="cannaiq-cart-button cannaiq-cart-button--solid cannaiq-cart-button--full" target="_blank" rel="noopener noreferrer">
<?php echo esc_html($settings['button_text']); ?>
</a>
<?php endif; ?>
</div>
</div>
</div>
<?php
}
}

View File

@@ -1,276 +0,0 @@
<?php
/**
* Elementor Promo Banner Widget
* Dark banner with deal text, product image, and shop button
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Promo_Banner_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_promo_banner';
}
public function get_title() {
return __('CannaiQ Promo Banner', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-banner';
}
public function get_categories() {
return ['cannaiq-templates'];
}
public function get_keywords() {
return ['cannaiq', 'promo', 'banner', 'deal', 'special'];
}
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(
'headline',
[
'label' => __('Headline', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => '2 for $35 | Eighth Flower (3.5g)',
'placeholder' => __('Deal headline...', 'cannaiq-menus'),
'label_block' => true,
]
);
$this->add_control(
'subtext',
[
'label' => __('Subtext', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'Lost Dutchmen ($20)',
'placeholder' => __('Optional subtext...', 'cannaiq-menus'),
'label_block' => true,
]
);
$this->add_control(
'image',
[
'label' => __('Product Image', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::MEDIA,
'default' => [
'url' => '',
],
]
);
$this->add_control(
'button_text',
[
'label' => __('Button Text', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'SHOP',
]
);
$this->add_control(
'button_url',
[
'label' => __('Button URL', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::URL,
'placeholder' => __('https://...', 'cannaiq-menus'),
'default' => [
'url' => '#',
],
]
);
$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(
'background_color',
[
'label' => __('Background Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1a1a2e',
]
);
$this->add_control(
'text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
]
);
$this->add_control(
'button_bg_color',
[
'label' => __('Button Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#22c55e',
]
);
$this->add_control(
'button_text_color',
[
'label' => __('Button Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
]
);
$this->add_control(
'border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 30,
],
],
'default' => [
'size' => 12,
],
]
);
$this->add_control(
'show_watermark',
[
'label' => __('Show Watermark Pattern', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$bg_color = $settings['background_color'];
$text_color = $settings['text_color'];
$btn_bg = $settings['button_bg_color'];
$btn_text = $settings['button_text_color'];
$radius = $settings['border_radius']['size'] . 'px';
$show_watermark = $settings['show_watermark'] === 'yes';
$url = $settings['button_url']['url'] ?? '#';
$target = !empty($settings['button_url']['is_external']) ? '_blank' : '_self';
?>
<div class="cannaiq-promo-banner" style="
background-color: <?php echo esc_attr($bg_color); ?>;
color: <?php echo esc_attr($text_color); ?>;
border-radius: <?php echo esc_attr($radius); ?>;
padding: 24px 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
position: relative;
overflow: hidden;
">
<?php if ($show_watermark): ?>
<div class="cannaiq-promo-watermark" style="
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.05;
font-size: 48px;
font-weight: bold;
letter-spacing: 8px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
pointer-events: none;
overflow: hidden;
">
<?php for ($i = 0; $i < 8; $i++): ?>
<span style="margin: 8px 16px;">DEAL</span>
<?php endfor; ?>
</div>
<?php endif; ?>
<div class="cannaiq-promo-content" style="position: relative; z-index: 1; flex: 1;">
<div class="cannaiq-promo-headline" style="
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
line-height: 1.3;
">
<?php echo esc_html($settings['headline']); ?>
<?php if (!empty($settings['subtext'])): ?>
<br><?php echo esc_html($settings['subtext']); ?>
<?php endif; ?>
</div>
<a href="<?php echo esc_url($url); ?>"
target="<?php echo esc_attr($target); ?>"
class="cannaiq-promo-button"
style="
display: inline-block;
background-color: <?php echo esc_attr($btn_bg); ?>;
color: <?php echo esc_attr($btn_text); ?>;
padding: 10px 24px;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
transition: opacity 0.2s;
">
<?php echo esc_html($settings['button_text']); ?>
</a>
</div>
<?php if (!empty($settings['image']['url'])): ?>
<div class="cannaiq-promo-image" style="
position: relative;
z-index: 1;
flex-shrink: 0;
">
<img src="<?php echo esc_url($settings['image']['url']); ?>"
alt="<?php echo esc_attr($settings['headline']); ?>"
style="
max-height: 100px;
width: auto;
object-fit: contain;
" />
</div>
<?php endif; ?>
</div>
<?php
}
}

View File

@@ -1,303 +0,0 @@
<?php
/**
* CannaIQ Cart Button Widget
*
* Displays a styled "Add to Cart" button that links to the menu/dispensary.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Cart_Button_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_cart_button';
}
public function get_title() {
return __('Cart Button', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-cart';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['cart', 'buy', 'order', 'shop', 'button', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'button_text',
[
'label' => __('Button Text', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'ADD TO CART',
]
);
$this->add_control(
'link_source',
[
'label' => __('Link Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom URL', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_url',
[
'label' => __('Custom URL', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::URL,
'placeholder' => 'https://dutchie.com/store/...',
'condition' => [
'link_source' => 'custom',
],
]
);
$this->add_control(
'open_in_new_tab',
[
'label' => __('Open in New Tab', '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_icon',
[
'label' => __('Show Icon', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => '',
]
);
$this->add_control(
'icon_position',
[
'label' => __('Icon Position', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'after',
'options' => [
'before' => __('Before Text', 'cannaiq-menus'),
'after' => __('After Text', 'cannaiq-menus'),
],
'condition' => [
'show_icon' => 'yes',
],
]
);
$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(
'button_style',
[
'label' => __('Button Style', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'solid',
'options' => [
'solid' => __('Solid', 'cannaiq-menus'),
'outline' => __('Outline', 'cannaiq-menus'),
],
]
);
$this->add_control(
'full_width',
[
'label' => __('Full Width', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => '',
]
);
$this->add_control(
'size',
[
'label' => __('Size', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'medium',
'options' => [
'small' => __('Small', 'cannaiq-menus'),
'medium' => __('Medium', 'cannaiq-menus'),
'large' => __('Large', 'cannaiq-menus'),
],
]
);
$this->add_control(
'background_color',
[
'label' => __('Background Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1f2937',
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button--solid' => 'background-color: {{VALUE}}; border-color: {{VALUE}};',
'{{WRAPPER}} .cannaiq-cart-button--outline' => 'border-color: {{VALUE}};',
'{{WRAPPER}} .cannaiq-cart-button--outline:hover' => 'background-color: {{VALUE}};',
],
]
);
$this->add_control(
'text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button--solid' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'outline_text_color',
[
'label' => __('Outline Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1f2937',
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button--outline' => 'color: {{VALUE}};',
],
'condition' => [
'button_style' => 'outline',
],
]
);
$this->add_control(
'hover_background_color',
[
'label' => __('Hover Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#374151',
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button--solid:hover' => 'background-color: {{VALUE}}; border-color: {{VALUE}};',
],
]
);
$this->add_control(
'border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 50,
],
],
'default' => [
'size' => 6,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-cart-button' => 'border-radius: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'typography',
'selector' => '{{WRAPPER}} .cannaiq-cart-button',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Get URL
$url = '#';
if ($settings['link_source'] === 'custom' && !empty($settings['custom_url']['url'])) {
$url = $settings['custom_url']['url'];
} else {
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$url = $cannaiq_current_product['menuUrl']
?? $cannaiq_current_product['menu_url']
?? $cannaiq_current_product['productUrl']
?? '#';
}
}
// Build classes
$classes = [
'cannaiq-cart-button',
'cannaiq-cart-button--' . $settings['button_style'],
];
if ($settings['full_width'] === 'yes') {
$classes[] = 'cannaiq-cart-button--full';
}
if ($settings['size'] !== 'medium') {
$classes[] = 'cannaiq-cart-button--' . $settings['size'];
}
// Target attribute
$target = $settings['open_in_new_tab'] === 'yes' ? ' target="_blank" rel="noopener noreferrer"' : '';
// Icon SVG (arrow right)
$icon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>';
?>
<a href="<?php echo esc_url($url); ?>" class="<?php echo esc_attr(implode(' ', $classes)); ?>"<?php echo $target; ?>>
<?php if ($settings['show_icon'] === 'yes' && $settings['icon_position'] === 'before'): ?>
<?php echo $icon; ?>
<?php endif; ?>
<?php echo esc_html($settings['button_text']); ?>
<?php if ($settings['show_icon'] === 'yes' && $settings['icon_position'] === 'after'): ?>
<?php echo $icon; ?>
<?php endif; ?>
</a>
<?php
}
}

View File

@@ -14,7 +14,7 @@ class CannaIQ_Menus_Category_List_Widget extends \Elementor\Widget_Base {
} }
public function get_title() { public function get_title() {
return __('CannaiQ Category List', 'cannaiq-menus'); return __('CannaIQ Category List', 'cannaiq-menus');
} }
public function get_icon() { public function get_icon() {

View File

@@ -1,216 +0,0 @@
<?php
/**
* CannaIQ Discount Ribbon Widget
*
* Displays discount percentage as a positioned badge/ribbon.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Discount_Ribbon_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_discount_ribbon';
}
public function get_title() {
return __('Discount Ribbon', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-price-table';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['discount', 'sale', 'ribbon', 'badge', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'source',
[
'label' => __('Discount Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom value', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_discount',
[
'label' => __('Discount Percentage', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 25,
'min' => 1,
'max' => 99,
'condition' => [
'source' => 'custom',
],
]
);
$this->add_control(
'format',
[
'label' => __('Display Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'ribbon',
'options' => [
'ribbon' => __('Ribbon', 'cannaiq-menus'),
'pill' => __('Pill', 'cannaiq-menus'),
'text' => __('Text Only', 'cannaiq-menus'),
],
]
);
$this->add_control(
'text_template',
[
'label' => __('Text Template', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => '{percent}% OFF',
'description' => __('Use {percent} as placeholder', 'cannaiq-menus'),
]
);
$this->add_control(
'hide_if_no_discount',
[
'label' => __('Hide if No Discount', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]
);
$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(
'size',
[
'label' => __('Size', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'medium',
'options' => [
'small' => __('Small', 'cannaiq-menus'),
'medium' => __('Medium', 'cannaiq-menus'),
'large' => __('Large', 'cannaiq-menus'),
],
]
);
$this->add_control(
'background_color',
[
'label' => __('Background Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ef4444',
'selectors' => [
'{{WRAPPER}} .cannaiq-discount-ribbon' => 'background-color: {{VALUE}};',
],
'condition' => [
'format!' => 'text',
],
]
);
$this->add_control(
'text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-discount-ribbon' => 'color: {{VALUE}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'typography',
'selector' => '{{WRAPPER}} .cannaiq-discount-ribbon',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Get discount percentage
$discount = 0;
if ($settings['source'] === 'custom') {
$discount = intval($settings['custom_discount']);
} else {
// Get from product context
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$original = $cannaiq_current_product['Prices'][0] ?? $cannaiq_current_product['regular_price'] ?? null;
$sale = $cannaiq_current_product['specialPrice'] ?? $cannaiq_current_product['sale_price'] ?? null;
if ($original && $sale && $original > $sale) {
$discount = round((($original - $sale) / $original) * 100);
}
}
}
// Hide if no discount and setting enabled
if ($discount <= 0 && $settings['hide_if_no_discount'] === 'yes') {
return;
}
// Build display text
$text = str_replace('{percent}', $discount, $settings['text_template']);
// Build classes
$classes = [
'cannaiq-discount-ribbon',
'cannaiq-discount-ribbon--' . $settings['format'],
];
if ($settings['size'] !== 'medium') {
$classes[] = 'cannaiq-discount-ribbon--' . $settings['size'];
}
printf(
'<span class="%s">%s</span>',
esc_attr(implode(' ', $classes)),
esc_html($text)
);
}
}

View File

@@ -1,983 +0,0 @@
<?php
/**
* CannaIQ Extended Dynamic Tags
*
* Additional dynamic tags for v2.0 modular component system.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
// Include effects icons helper
require_once dirname(__DIR__) . '/includes/effects-icons.php';
/**
* Register extended CannaIQ dynamic tags
*/
add_action('elementor/dynamic_tags/register', function($dynamic_tags_manager) {
// Register new tags
$dynamic_tags_manager->register(new CannaIQ_Discount_Percent_Tag());
$dynamic_tags_manager->register(new CannaIQ_Discount_Badge_Tag());
$dynamic_tags_manager->register(new CannaIQ_Strain_Badge_Tag());
$dynamic_tags_manager->register(new CannaIQ_THC_Badge_Tag());
$dynamic_tags_manager->register(new CannaIQ_CBD_Badge_Tag());
$dynamic_tags_manager->register(new CannaIQ_Effects_Chips_Tag());
$dynamic_tags_manager->register(new CannaIQ_Single_Effect_Tag());
$dynamic_tags_manager->register(new CannaIQ_Terpenes_Tag());
$dynamic_tags_manager->register(new CannaIQ_Price_Display_Tag());
$dynamic_tags_manager->register(new CannaIQ_Sale_Price_Tag());
$dynamic_tags_manager->register(new CannaIQ_Original_Price_Tag());
$dynamic_tags_manager->register(new CannaIQ_Menu_URL_Tag());
$dynamic_tags_manager->register(new CannaIQ_Subcategory_Tag());
$dynamic_tags_manager->register(new CannaIQ_Stock_Quantity_Tag());
$dynamic_tags_manager->register(new CannaIQ_Stock_Status_Tag());
$dynamic_tags_manager->register(new CannaIQ_Product_ID_Tag());
$dynamic_tags_manager->register(new CannaIQ_Store_ID_Tag());
$dynamic_tags_manager->register(new CannaIQ_Weight_Options_Tag());
}, 20); // Priority 20 to run after base tags
/**
* Discount Percentage Tag
*/
class CannaIQ_Discount_Percent_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-discount-percent';
}
public function get_title() {
return __('Discount %', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('format', [
'label' => __('Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'off',
'options' => [
'off' => 'XX% OFF',
'percent' => 'XX%',
'number' => 'XX',
],
]);
}
public function render() {
$product = $this->get_current_product();
$format = $this->get_settings('format');
$original = $product['Prices'][0] ?? $product['regular_price'] ?? null;
$sale = $product['specialPrice'] ?? $product['sale_price'] ?? null;
if (!$original || !$sale || $original <= $sale) {
return;
}
$percent = round((($original - $sale) / $original) * 100);
switch ($format) {
case 'off':
echo esc_html($percent . '% OFF');
break;
case 'percent':
echo esc_html($percent . '%');
break;
case 'number':
echo esc_html($percent);
break;
}
}
}
/**
* Discount Badge Tag (HTML)
*/
class CannaIQ_Discount_Badge_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-discount-badge';
}
public function get_title() {
return __('Discount Badge', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('style', [
'label' => __('Style', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'ribbon',
'options' => [
'ribbon' => 'Ribbon',
'pill' => 'Pill',
],
]);
}
public function render() {
$product = $this->get_current_product();
$style = $this->get_settings('style');
$original = $product['Prices'][0] ?? $product['regular_price'] ?? null;
$sale = $product['specialPrice'] ?? $product['sale_price'] ?? null;
if (!$original || !$sale || $original <= $sale) {
return;
}
$percent = round((($original - $sale) / $original) * 100);
$class = 'cannaiq-discount-ribbon cannaiq-discount-ribbon--' . $style;
printf(
'<span class="%s">%s%% OFF</span>',
esc_attr($class),
esc_html($percent)
);
}
}
/**
* Strain Badge Tag (HTML)
*/
class CannaIQ_Strain_Badge_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-strain-badge';
}
public function get_title() {
return __('Strain Badge', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('style', [
'label' => __('Style', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'pill',
'options' => [
'pill' => 'Pill',
'text' => 'Text Only',
],
]);
}
public function render() {
$product = $this->get_current_product();
$style = $this->get_settings('style');
$strain = strtolower($product['strainType'] ?? $product['strain_type'] ?? '');
if (empty($strain) || !in_array($strain, ['sativa', 'indica', 'hybrid'])) {
return;
}
$colors = [
'sativa' => '#22c55e',
'indica' => '#8b5cf6',
'hybrid' => '#f97316',
];
$color = $colors[$strain];
$class = 'cannaiq-strain-badge cannaiq-strain-badge--' . $style . ' cannaiq-strain-badge--' . $strain;
$css_style = $style === 'pill'
? sprintf('background-color: %s; color: white;', $color)
: sprintf('color: %s;', $color);
printf(
'<span class="%s" style="%s">%s</span>',
esc_attr($class),
esc_attr($css_style),
esc_html(strtoupper($strain))
);
}
}
/**
* THC Badge Tag (HTML)
*/
class CannaIQ_THC_Badge_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-thc-badge';
}
public function get_title() {
return __('THC Badge', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('style', [
'label' => __('Style', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'badge',
'options' => [
'badge' => 'Badge',
'pill' => 'Pill',
'text' => 'Text Only',
],
]);
}
public function render() {
$product = $this->get_current_product();
$style = $this->get_settings('style');
$thc = $product['THCContent']['range'][0]
?? $product['THC']
?? $product['thc_percentage']
?? null;
if (!$thc || $thc <= 0) {
return;
}
$class = 'cannaiq-potency-badge cannaiq-potency-badge--' . $style;
$formatted = number_format((float)$thc, 1) . '% THC';
printf(
'<span class="%s">%s</span>',
esc_attr($class),
esc_html($formatted)
);
}
}
/**
* CBD Badge Tag (HTML)
*/
class CannaIQ_CBD_Badge_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-cbd-badge';
}
public function get_title() {
return __('CBD Badge', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('style', [
'label' => __('Style', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'badge',
'options' => [
'badge' => 'Badge',
'pill' => 'Pill',
'text' => 'Text Only',
],
]);
}
public function render() {
$product = $this->get_current_product();
$style = $this->get_settings('style');
$cbd = $product['CBDContent']['range'][0]
?? $product['CBD']
?? $product['cbd_percentage']
?? null;
if (!$cbd || $cbd <= 0) {
return;
}
$class = 'cannaiq-potency-badge cannaiq-potency-badge--' . $style;
$formatted = number_format((float)$cbd, 1) . '% CBD';
printf(
'<span class="%s">%s</span>',
esc_attr($class),
esc_html($formatted)
);
}
}
/**
* Effects Chips Tag (HTML)
*/
class CannaIQ_Effects_Chips_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-effects-chips';
}
public function get_title() {
return __('Effects Chips', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('limit', [
'label' => __('Max Effects', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 3,
'min' => 1,
'max' => 10,
]);
$this->add_control('show_icons', [
'label' => __('Show Icons', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]);
}
public function render() {
$product = $this->get_current_product();
$limit = (int)$this->get_settings('limit') ?: 3;
$show_icons = $this->get_settings('show_icons') === 'yes';
$effects = $product['effects'] ?? [];
if (empty($effects) || !is_array($effects)) {
return;
}
// If associative array with scores, sort by score
if (!isset($effects[0])) {
arsort($effects);
$effects = array_keys($effects);
}
$effects = array_slice($effects, 0, $limit);
echo cannaiq_render_effects($effects, [
'limit' => $limit,
'show_icon' => $show_icons,
'size' => 'medium',
]);
}
}
/**
* Single Effect Tag
*/
class CannaIQ_Single_Effect_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-single-effect';
}
public function get_title() {
return __('Single Effect', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('effect_index', [
'label' => __('Effect Index', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 1,
'min' => 1,
'max' => 10,
'description' => __('1 = first effect, 2 = second, etc.', 'cannaiq-menus'),
]);
$this->add_control('show_icon', [
'label' => __('Show Icon', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]);
}
public function render() {
$product = $this->get_current_product();
$index = (int)$this->get_settings('effect_index') - 1; // Convert to 0-based
$show_icon = $this->get_settings('show_icon') === 'yes';
$effects = $product['effects'] ?? [];
if (empty($effects) || !is_array($effects)) {
return;
}
// If associative array with scores, sort by score and get keys
if (!isset($effects[0])) {
arsort($effects);
$effects = array_keys($effects);
}
if (!isset($effects[$index])) {
return;
}
$effect = $effects[$index];
echo cannaiq_render_effect_chip($effect, [
'show_icon' => $show_icon,
'size' => 'medium',
]);
}
}
/**
* Terpenes Tag (HTML)
*/
class CannaIQ_Terpenes_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-terpenes';
}
public function get_title() {
return __('Terpenes', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('format', [
'label' => __('Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'chips',
'options' => [
'chips' => 'Chips',
'list' => 'List',
'text' => 'Text',
],
]);
$this->add_control('limit', [
'label' => __('Max Terpenes', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 3,
'min' => 1,
'max' => 10,
]);
}
public function render() {
$product = $this->get_current_product();
$format = $this->get_settings('format');
$limit = (int)$this->get_settings('limit') ?: 3;
$terpenes = $product['terpenes'] ?? [];
if (empty($terpenes) || !is_array($terpenes)) {
return;
}
$terpenes = array_slice($terpenes, 0, $limit);
switch ($format) {
case 'chips':
echo '<div class="cannaiq-terpenes cannaiq-terpenes--chips">';
foreach ($terpenes as $terp) {
$name = $terp['name'] ?? '';
$percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : '';
printf(
'<span class="cannaiq-terpene-chip"><span class="cannaiq-terpene-chip__name">%s</span><span class="cannaiq-terpene-chip__percent">%s</span></span>',
esc_html($name),
esc_html($percent)
);
}
echo '</div>';
break;
case 'list':
echo '<div class="cannaiq-terpenes cannaiq-terpenes--list">';
foreach ($terpenes as $terp) {
$name = $terp['name'] ?? '';
$percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : '';
printf(
'<div class="cannaiq-terpene-item"><span>%s</span><span>%s</span></div>',
esc_html($name),
esc_html($percent)
);
}
echo '</div>';
break;
case 'text':
$parts = [];
foreach ($terpenes as $terp) {
$name = $terp['name'] ?? '';
$percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : '';
$parts[] = $name . ($percent ? ' ' . $percent : '');
}
echo esc_html(implode(', ', $parts));
break;
}
}
}
/**
* Price Display Tag (with sale handling)
*/
class CannaIQ_Price_Display_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-price-display';
}
public function get_title() {
return __('Price Display', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('show_original', [
'label' => __('Show Original on Sale', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]);
}
public function render() {
$product = $this->get_current_product();
$show_original = $this->get_settings('show_original') === 'yes';
$original = $product['Prices'][0] ?? $product['regular_price'] ?? null;
$sale = $product['specialPrice'] ?? $product['sale_price'] ?? null;
if (!$original || $original <= 0) {
return;
}
$is_on_sale = $sale && $sale > 0 && $sale < $original;
echo '<span class="cannaiq-price-block">';
if ($is_on_sale) {
if ($show_original) {
printf(
'<span class="cannaiq-price-block__original">$%s</span>',
esc_html(number_format((float)$original, 2))
);
}
printf(
'<span class="cannaiq-price-block__sale">$%s</span>',
esc_html(number_format((float)$sale, 2))
);
} else {
printf(
'<span class="cannaiq-price-block__regular">$%s</span>',
esc_html(number_format((float)$original, 2))
);
}
echo '</span>';
}
}
/**
* Sale Price Tag
*/
class CannaIQ_Sale_Price_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-price-sale';
}
public function get_title() {
return __('Sale Price', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$sale = $product['specialPrice'] ?? $product['sale_price'] ?? null;
if ($sale && $sale > 0) {
echo '$' . number_format((float)$sale, 2);
}
}
}
/**
* Original Price Tag
*/
class CannaIQ_Original_Price_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-price-original';
}
public function get_title() {
return __('Original Price', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$original = $product['Prices'][0] ?? $product['regular_price'] ?? null;
if ($original && $original > 0) {
echo '$' . number_format((float)$original, 2);
}
}
}
/**
* Menu URL Tag
*/
class CannaIQ_Menu_URL_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-menu-url';
}
public function get_title() {
return __('Menu URL', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::URL_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$url = $product['menuUrl']
?? $product['menu_url']
?? $product['productUrl']
?? '';
echo esc_url($url);
}
}
/**
* Subcategory Tag
*/
class CannaIQ_Subcategory_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-subcategory';
}
public function get_title() {
return __('Subcategory', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$subcategory = $product['subcategory']
?? $product['subCategory']
?? '';
echo esc_html($subcategory);
}
}
/**
* Stock Quantity Tag
*/
class CannaIQ_Stock_Quantity_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-stock-qty';
}
public function get_title() {
return __('Stock Quantity', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$qty = $product['POSMetaData']['children'][0]['quantity']
?? $product['quantity']
?? null;
if ($qty !== null) {
echo (int)$qty;
}
}
}
/**
* Stock Status Tag (HTML badge)
*/
class CannaIQ_Stock_Status_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-stock-status';
}
public function get_title() {
return __('Stock Status Badge', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('style', [
'label' => __('Style', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'badge',
'options' => [
'badge' => 'Badge',
'text' => 'Text',
'dot' => 'Dot + Text',
],
]);
}
public function render() {
$product = $this->get_current_product();
$style = $this->get_settings('style');
$status = $product['Status'] ?? '';
$in_stock = ($status === 'Active' || $status === 'In Stock' || !empty($product['in_stock']));
$text = $in_stock ? 'In Stock' : 'Out of Stock';
$class = 'cannaiq-stock-indicator cannaiq-stock-indicator--' . ($in_stock ? 'in-stock' : 'out-of-stock');
if ($style === 'badge') {
$class .= ' cannaiq-stock-indicator--badge';
}
printf('<span class="%s">', esc_attr($class));
if ($style === 'dot') {
echo '<span class="cannaiq-stock-indicator__dot"></span>';
}
echo esc_html($text);
echo '</span>';
}
}
/**
* Product ID Tag (for analytics)
*/
class CannaIQ_Product_ID_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-product-id';
}
public function get_title() {
return __('Product ID', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$id = $product['id'] ?? $product['ID'] ?? $product['product_id'] ?? '';
echo esc_html($id);
}
}
/**
* Store ID Tag (for analytics)
*/
class CannaIQ_Store_ID_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-store-id';
}
public function get_title() {
return __('Store ID', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$id = $product['dispensary_id'] ?? $product['store_id'] ?? $product['storeId'] ?? '';
echo esc_html($id);
}
}
/**
* Weight Options Tag (variant selector)
*/
class CannaIQ_Weight_Options_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-weight-options';
}
public function get_title() {
return __('Weight Options', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('format', [
'label' => __('Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'pills',
'options' => [
'pills' => 'Pills',
'dropdown' => 'Dropdown',
'text' => 'Text List',
],
]);
$this->add_control('show_price', [
'label' => __('Show Price', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
]);
}
public function render() {
$product = $this->get_current_product();
$format = $this->get_settings('format');
$show_price = $this->get_settings('show_price') === 'yes';
// Try to get variants/options from various data structures
$variants = $product['variants'] ?? [];
$options = $product['Options'] ?? $product['rawOptions'] ?? [];
$posChildren = $product['POSMetaData']['children'] ?? [];
// Build unified options array
$weight_options = [];
// From variants (new API format)
if (!empty($variants) && is_array($variants)) {
foreach ($variants as $v) {
$weight_options[] = [
'label' => $v['option'] ?? $v['key'] ?? '',
'price' => $v['price_rec'] ?? $v['recPrice'] ?? $v['price'] ?? null,
'special_price' => $v['price_rec_special'] ?? $v['recSpecialPrice'] ?? null,
];
}
}
// From POSMetaData children (Dutchie raw format)
elseif (!empty($posChildren) && is_array($posChildren)) {
foreach ($posChildren as $child) {
$weight_options[] = [
'label' => $child['option'] ?? $child['key'] ?? '',
'price' => $child['recPrice'] ?? $child['price'] ?? null,
'special_price' => $child['recSpecialPrice'] ?? null,
];
}
}
// From simple options array
elseif (!empty($options) && is_array($options)) {
foreach ($options as $opt) {
if (is_string($opt)) {
$weight_options[] = ['label' => $opt, 'price' => null];
} elseif (is_array($opt)) {
$weight_options[] = [
'label' => $opt['label'] ?? $opt['option'] ?? '',
'price' => $opt['price'] ?? null,
];
}
}
}
if (empty($weight_options)) {
return;
}
switch ($format) {
case 'pills':
echo '<div class="cannaiq-weight-options">';
foreach ($weight_options as $i => $opt) {
$selected = $i === 0 ? ' cannaiq-weight-option--selected' : '';
$price_str = '';
if ($show_price && $opt['price']) {
$price = $opt['special_price'] ?? $opt['price'];
$price_str = '<span class="cannaiq-weight-option__price">$' . number_format((float)$price, 2) . '</span>';
}
printf(
'<span class="cannaiq-weight-option%s" data-option="%s">%s%s</span>',
esc_attr($selected),
esc_attr($opt['label']),
esc_html($opt['label']),
$price_str
);
}
echo '</div>';
break;
case 'dropdown':
echo '<div class="cannaiq-weight-options cannaiq-weight-options--dropdown">';
echo '<select class="cannaiq-weight-select">';
foreach ($weight_options as $opt) {
$price_str = '';
if ($show_price && $opt['price']) {
$price = $opt['special_price'] ?? $opt['price'];
$price_str = ' - $' . number_format((float)$price, 2);
}
printf(
'<option value="%s">%s%s</option>',
esc_attr($opt['label']),
esc_html($opt['label']),
esc_html($price_str)
);
}
echo '</select>';
echo '</div>';
break;
case 'text':
$labels = array_column($weight_options, 'label');
echo esc_html(implode(', ', $labels));
break;
}
}
}

View File

@@ -17,7 +17,7 @@ add_action('elementor/dynamic_tags/register', function($dynamic_tags_manager) {
// Register CannaIQ group // Register CannaIQ group
$dynamic_tags_manager->register_group('cannaiq', [ $dynamic_tags_manager->register_group('cannaiq', [
'title' => __('CannaiQ Product', 'cannaiq-menus') 'title' => __('CannaIQ Product', 'cannaiq-menus')
]); ]);
// Register all tags // Register all tags

View File

@@ -1,288 +0,0 @@
<?php
/**
* CannaIQ Effects Display Widget
*
* Displays product effects as styled chips with optional icons.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
// Include effects icons helper
require_once dirname(__DIR__) . '/includes/effects-icons.php';
class CannaIQ_Effects_Display_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_effects_display';
}
public function get_title() {
return __('Effects Display', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-bullet-list';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['effects', 'happy', 'relaxed', 'sleepy', 'chips', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'source',
[
'label' => __('Effects Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom values', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_effects',
[
'label' => __('Custom Effects', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'Happy, Relaxed, Creative',
'description' => __('Comma-separated list of effects', 'cannaiq-menus'),
'condition' => [
'source' => 'custom',
],
]
);
$this->add_control(
'limit',
[
'label' => __('Max Effects', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 3,
'min' => 1,
'max' => 10,
]
);
$this->add_control(
'show_icons',
[
'label' => __('Show Icons', '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(
'use_colors',
[
'label' => __('Colored Chips', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
'description' => __('Use effect-specific colors', '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(
'size',
[
'label' => __('Size', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'medium',
'options' => [
'small' => __('Small', 'cannaiq-menus'),
'medium' => __('Medium', 'cannaiq-menus'),
'large' => __('Large', 'cannaiq-menus'),
],
]
);
$this->add_control(
'gap',
[
'label' => __('Gap', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 2,
'max' => 20,
],
],
'default' => [
'size' => 8,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-effects-container' => 'gap: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_control(
'default_background',
[
'label' => __('Default Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f3f4f6',
'condition' => [
'use_colors!' => 'yes',
],
]
);
$this->add_control(
'text_color',
[
'label' => __('Text Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1f2937',
'selectors' => [
'{{WRAPPER}} .cannaiq-effect-chip' => 'color: {{VALUE}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'typography',
'selector' => '{{WRAPPER}} .cannaiq-effect-chip',
]
);
$this->add_control(
'border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 0,
'max' => 50,
],
],
'default' => [
'size' => 999,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-effect-chip' => 'border-radius: {{SIZE}}{{UNIT}};',
],
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Get effects
$effects = [];
if ($settings['source'] === 'custom') {
$effects_string = $settings['custom_effects'];
$effects = array_map('trim', explode(',', $effects_string));
} else {
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$raw_effects = $cannaiq_current_product['effects'] ?? [];
if (is_array($raw_effects)) {
// If effects is associative array with scores, sort by score
if (isset($raw_effects[0]) && !is_array($raw_effects[0])) {
$effects = $raw_effects;
} else {
// Sort by value descending and get keys
arsort($raw_effects);
$effects = array_keys($raw_effects);
}
}
}
}
if (empty($effects)) {
return;
}
// Apply limit
$limit = intval($settings['limit']);
$effects = array_slice($effects, 0, $limit);
// Determine icon size
$icon_size = $settings['size'] === 'small' ? 12 : ($settings['size'] === 'large' ? 20 : 16);
?>
<div class="cannaiq-effects-container">
<?php foreach ($effects as $effect): ?>
<?php
$effect_name = ucfirst(strtolower(trim($effect)));
$effect_key = strtolower(trim($effect));
// Get color if using colors
$color = '#6B7280'; // Default gray
$style = '';
if ($settings['use_colors'] === 'yes') {
$color = cannaiq_get_effect_color($effect);
$style = sprintf('--effect-color: %s;', esc_attr($color));
} else {
$style = sprintf('background: %s; border-color: %s;',
esc_attr($settings['default_background']),
esc_attr($settings['default_background'])
);
}
// Build classes
$classes = ['cannaiq-effect-chip'];
if ($settings['size'] !== 'medium') {
$classes[] = 'cannaiq-effect-chip--' . $settings['size'];
}
?>
<span class="<?php echo esc_attr(implode(' ', $classes)); ?>" style="<?php echo esc_attr($style); ?>">
<?php if ($settings['show_icons'] === 'yes'): ?>
<?php echo cannaiq_get_effect_icon($effect, [
'size' => $icon_size,
'color' => $settings['use_colors'] === 'yes' ? $color : 'currentColor',
]); ?>
<?php endif; ?>
<span class="cannaiq-effect-chip__label"><?php echo esc_html($effect_name); ?></span>
</span>
<?php endforeach; ?>
</div>
<?php
}
}

View File

@@ -1,309 +0,0 @@
<?php
/**
* CannaIQ Price Block Widget
*
* Displays product price with optional sale price and weight.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Price_Block_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_price_block';
}
public function get_title() {
return __('Price Block', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-product-price';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['price', 'sale', 'cost', 'money', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'source',
[
'label' => __('Price Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom values', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_price',
[
'label' => __('Regular Price', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 45,
'min' => 0,
'step' => 0.01,
'condition' => [
'source' => 'custom',
],
]
);
$this->add_control(
'custom_sale_price',
[
'label' => __('Sale Price (optional)', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => '',
'min' => 0,
'step' => 0.01,
'condition' => [
'source' => 'custom',
],
]
);
$this->add_control(
'show_original_when_sale',
[
'label' => __('Show Original Price on Sale', '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_weight',
[
'label' => __('Show Weight', '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(
'custom_weight',
[
'label' => __('Weight Text', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => '1/8 oz',
'condition' => [
'source' => 'custom',
'show_weight' => 'yes',
],
]
);
$this->add_control(
'layout',
[
'label' => __('Layout', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'inline',
'options' => [
'inline' => __('Inline', 'cannaiq-menus'),
'stacked' => __('Stacked', 'cannaiq-menus'),
],
]
);
$this->add_control(
'currency_symbol',
[
'label' => __('Currency Symbol', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => '$',
]
);
$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(
'size',
[
'label' => __('Size', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'medium',
'options' => [
'small' => __('Small', 'cannaiq-menus'),
'medium' => __('Medium', 'cannaiq-menus'),
'large' => __('Large', 'cannaiq-menus'),
],
]
);
$this->add_control(
'price_color',
[
'label' => __('Price Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1f2937',
'selectors' => [
'{{WRAPPER}} .cannaiq-price-block__regular' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'sale_color',
[
'label' => __('Sale Price Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#dc2626',
'selectors' => [
'{{WRAPPER}} .cannaiq-price-block__sale' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'original_color',
[
'label' => __('Original Price Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#9ca3af',
'selectors' => [
'{{WRAPPER}} .cannaiq-price-block__original' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'weight_color',
[
'label' => __('Weight Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#9ca3af',
'selectors' => [
'{{WRAPPER}} .cannaiq-price-block__weight' => 'color: {{VALUE}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'price_typography',
'label' => __('Price Typography', 'cannaiq-menus'),
'selector' => '{{WRAPPER}} .cannaiq-price-block__sale, {{WRAPPER}} .cannaiq-price-block__regular',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Get price values
$regular_price = 0;
$sale_price = null;
$weight = '';
if ($settings['source'] === 'custom') {
$regular_price = floatval($settings['custom_price']);
$sale_price = !empty($settings['custom_sale_price']) ? floatval($settings['custom_sale_price']) : null;
$weight = $settings['custom_weight'];
} else {
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$regular_price = $cannaiq_current_product['Prices'][0]
?? $cannaiq_current_product['recPrices'][0]
?? $cannaiq_current_product['regular_price']
?? 0;
$sale_price = $cannaiq_current_product['specialPrice']
?? $cannaiq_current_product['sale_price']
?? null;
// Check POSMetaData for prices
if (isset($cannaiq_current_product['POSMetaData']['children'][0])) {
$child = $cannaiq_current_product['POSMetaData']['children'][0];
if (isset($child['price'])) {
$regular_price = $child['price'];
}
if (isset($child['specialPrice']) && $child['specialPrice'] > 0) {
$sale_price = $child['specialPrice'];
}
}
$weight = $cannaiq_current_product['Options'][0]
?? $cannaiq_current_product['rawOptions'][0]
?? $cannaiq_current_product['weight']
?? '';
}
}
$regular_price = floatval($regular_price);
if ($regular_price <= 0) {
return;
}
// Determine if on sale
$is_on_sale = $sale_price !== null && floatval($sale_price) > 0 && floatval($sale_price) < $regular_price;
// Build classes
$classes = ['cannaiq-price-block'];
if ($settings['layout'] === 'stacked') {
$classes[] = 'cannaiq-price-block--stacked';
}
if ($settings['size'] !== 'medium') {
$classes[] = 'cannaiq-price-block--' . $settings['size'];
}
$currency = $settings['currency_symbol'];
?>
<div class="<?php echo esc_attr(implode(' ', $classes)); ?>">
<?php if ($settings['show_weight'] === 'yes' && !empty($weight)): ?>
<span class="cannaiq-price-block__weight"><?php echo esc_html($weight); ?></span>
<?php endif; ?>
<?php if ($is_on_sale): ?>
<?php if ($settings['show_original_when_sale'] === 'yes'): ?>
<span class="cannaiq-price-block__original"><?php echo esc_html($currency . number_format($regular_price, 2)); ?></span>
<?php endif; ?>
<span class="cannaiq-price-block__sale"><?php echo esc_html($currency . number_format(floatval($sale_price), 2)); ?></span>
<?php else: ?>
<span class="cannaiq-price-block__regular"><?php echo esc_html($currency . number_format($regular_price, 2)); ?></span>
<?php endif; ?>
</div>
<?php
}
}

View File

@@ -14,7 +14,7 @@ class CannaIQ_Menus_Product_Grid_Widget extends \Elementor\Widget_Base {
} }
public function get_title() { public function get_title() {
return __('CannaiQ Product Grid', 'cannaiq-menus'); return __('CannaIQ Product Grid', 'cannaiq-menus');
} }
public function get_icon() { public function get_icon() {

View File

@@ -1,390 +0,0 @@
<?php
/**
* CannaIQ Product Image Overlay Widget
*
* Displays product image with positioned badge overlays.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Product_Image_Overlay_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_product_image_overlay';
}
public function get_title() {
return __('Product Image + Badges', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-image';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['image', 'product', 'photo', 'overlay', 'badges', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Image', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'image_source',
[
'label' => __('Image Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom image', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_image',
[
'label' => __('Choose Image', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::MEDIA,
'default' => [
'url' => \Elementor\Utils::get_placeholder_image_src(),
],
'condition' => [
'image_source' => 'custom',
],
]
);
$this->add_control(
'fallback_image',
[
'label' => __('Fallback Image', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::MEDIA,
'description' => __('Shown if product has no image', 'cannaiq-menus'),
]
);
$this->add_control(
'aspect_ratio',
[
'label' => __('Aspect Ratio', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '1/1',
'options' => [
'1/1' => __('Square (1:1)', 'cannaiq-menus'),
'4/3' => __('4:3', 'cannaiq-menus'),
'3/4' => __('3:4', 'cannaiq-menus'),
'16/9' => __('16:9', 'cannaiq-menus'),
'auto' => __('Auto', 'cannaiq-menus'),
],
]
);
$this->add_control(
'hover_effect',
[
'label' => __('Hover Effect', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'zoom',
'options' => [
'none' => __('None', 'cannaiq-menus'),
'zoom' => __('Zoom', 'cannaiq-menus'),
],
]
);
$this->end_controls_section();
// Overlay Badges Section
$this->start_controls_section(
'overlays_section',
[
'label' => __('Badge Overlays', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$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(
'discount_position',
[
'label' => __('Discount Position', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'top-left',
'options' => [
'top-left' => __('Top Left', 'cannaiq-menus'),
'top-right' => __('Top Right', 'cannaiq-menus'),
'bottom-left' => __('Bottom Left', 'cannaiq-menus'),
'bottom-right' => __('Bottom Right', 'cannaiq-menus'),
],
'condition' => [
'show_discount_badge' => 'yes',
],
]
);
$this->add_control(
'show_strain_badge',
[
'label' => __('Show Strain 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(
'strain_position',
[
'label' => __('Strain Position', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'bottom-left',
'options' => [
'top-left' => __('Top Left', 'cannaiq-menus'),
'top-right' => __('Top Right', 'cannaiq-menus'),
'bottom-left' => __('Bottom Left', 'cannaiq-menus'),
'bottom-right' => __('Bottom Right', 'cannaiq-menus'),
],
'condition' => [
'show_strain_badge' => 'yes',
],
]
);
$this->add_control(
'show_thc_badge',
[
'label' => __('Show THC Badge', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => '',
]
);
$this->add_control(
'thc_position',
[
'label' => __('THC Position', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'bottom-right',
'options' => [
'top-left' => __('Top Left', 'cannaiq-menus'),
'top-right' => __('Top Right', 'cannaiq-menus'),
'bottom-left' => __('Bottom Left', 'cannaiq-menus'),
'bottom-right' => __('Bottom Right', 'cannaiq-menus'),
],
'condition' => [
'show_thc_badge' => 'yes',
],
]
);
$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(
'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-product-image' => 'border-radius: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_control(
'background_color',
[
'label' => __('Background Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f9fafb',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-image' => 'background-color: {{VALUE}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Box_Shadow::get_type(),
[
'name' => 'box_shadow',
'selector' => '{{WRAPPER}} .cannaiq-product-image',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Get image URL
$image_url = '';
if ($settings['image_source'] === 'custom') {
$image_url = $settings['custom_image']['url'] ?? '';
} else {
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$image_url = $cannaiq_current_product['Image']
?? $cannaiq_current_product['images'][0]['url']
?? $cannaiq_current_product['image_url']
?? '';
}
}
// Use fallback if no image
if (empty($image_url) && !empty($settings['fallback_image']['url'])) {
$image_url = $settings['fallback_image']['url'];
}
// Get product name for alt text
$alt_text = '';
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$alt_text = $cannaiq_current_product['Name'] ?? $cannaiq_current_product['name'] ?? '';
}
// Aspect ratio style
$aspect_style = '';
if ($settings['aspect_ratio'] !== 'auto') {
$aspect_style = 'aspect-ratio: ' . $settings['aspect_ratio'] . ';';
}
// Hover class
$hover_class = $settings['hover_effect'] === 'zoom' ? 'cannaiq-product-image--hover-zoom' : '';
// Group badges by position
$badges_by_position = [];
if ($settings['show_discount_badge'] === 'yes') {
$badges_by_position[$settings['discount_position']][] = 'discount';
}
if ($settings['show_strain_badge'] === 'yes') {
$badges_by_position[$settings['strain_position']][] = 'strain';
}
if ($settings['show_thc_badge'] === 'yes') {
$badges_by_position[$settings['thc_position']][] = 'thc';
}
?>
<div class="cannaiq-product-image <?php echo esc_attr($hover_class); ?>" style="<?php echo esc_attr($aspect_style); ?>">
<?php if (!empty($image_url)): ?>
<img src="<?php echo esc_url($image_url); ?>" alt="<?php echo esc_attr($alt_text); ?>" />
<?php endif; ?>
<?php foreach ($badges_by_position as $position => $badges): ?>
<div class="cannaiq-product-image__overlay cannaiq-product-image__overlay--<?php echo esc_attr($position); ?>">
<div class="cannaiq-product-image__badges">
<?php foreach ($badges as $badge_type): ?>
<?php $this->render_badge($badge_type); ?>
<?php endforeach; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php
}
private function render_badge($type) {
global $cannaiq_current_product;
switch ($type) {
case 'discount':
if (!isset($cannaiq_current_product)) return;
$original = $cannaiq_current_product['Prices'][0]
?? $cannaiq_current_product['regular_price']
?? null;
$sale = $cannaiq_current_product['specialPrice']
?? $cannaiq_current_product['sale_price']
?? null;
if ($original && $sale && $original > $sale) {
$percent = round((($original - $sale) / $original) * 100);
echo '<span class="cannaiq-discount-ribbon cannaiq-discount-ribbon--pill">' . esc_html($percent) . '% OFF</span>';
}
break;
case 'strain':
if (!isset($cannaiq_current_product)) return;
$strain = strtolower($cannaiq_current_product['strainType']
?? $cannaiq_current_product['strain_type']
?? '');
if (!empty($strain) && in_array($strain, ['sativa', 'indica', 'hybrid'])) {
$colors = [
'sativa' => '#22c55e',
'indica' => '#8b5cf6',
'hybrid' => '#f97316',
];
$color = $colors[$strain];
echo '<span class="cannaiq-strain-badge cannaiq-strain-badge--pill" style="background-color: ' . esc_attr($color) . '; color: white;">' . esc_html(strtoupper($strain)) . '</span>';
}
break;
case 'thc':
if (!isset($cannaiq_current_product)) return;
$thc = $cannaiq_current_product['THCContent']['range'][0]
?? $cannaiq_current_product['THC']
?? $cannaiq_current_product['thc_percentage']
?? null;
if ($thc !== null && $thc > 0) {
echo '<span class="cannaiq-potency-badge cannaiq-potency-badge--pill" style="background-color: #1f2937; color: white;">' . esc_html(number_format((float)$thc, 1)) . '% THC</span>';
}
break;
}
}
}

View File

@@ -17,7 +17,7 @@ class CannaIQ_Product_Loop_Widget extends \Elementor\Widget_Base {
} }
public function get_title() { public function get_title() {
return __('CannaiQ Product Loop', 'cannaiq-menus'); return __('CannaIQ Product Loop', 'cannaiq-menus');
} }
public function get_icon() { public function get_icon() {
@@ -43,7 +43,15 @@ class CannaIQ_Product_Loop_Widget extends \Elementor\Widget_Base {
] ]
); );
// Store ID is determined by API key - no control needed $this->add_control(
'dispensary_id',
[
'label' => __('Store ID', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => '',
'description' => __('Enter your store/dispensary ID from CannaIQ', 'cannaiq-menus'),
]
);
$this->add_control( $this->add_control(
'limit', 'limit',
@@ -768,17 +776,16 @@ class CannaIQ_Product_Loop_Widget extends \Elementor\Widget_Base {
} }
protected function fetch_products($settings) { protected function fetch_products($settings) {
// Get API key from WordPress settings $dispensary_id = $settings['dispensary_id'];
$api_key = get_option('cannaiq_api_token');
if (empty($api_key)) { if (empty($dispensary_id)) {
return []; return [];
} }
// Build API URL - API key determines the store // Build API URL - use the payload query endpoint
$api_url = CANNAIQ_MENUS_API_URL . '/products'; $api_url = CANNAIQ_MENUS_API_URL . '/payloads/store/' . intval($dispensary_id) . '/query';
// Build query parameters // Build query parameters matching the API expected format
$params = []; $params = [];
if (!empty($settings['category'])) { if (!empty($settings['category'])) {
@@ -798,15 +805,15 @@ class CannaIQ_Product_Loop_Widget extends \Elementor\Widget_Base {
} }
if (!empty($settings['min_thc'])) { if (!empty($settings['min_thc'])) {
$params['min_thc'] = $settings['min_thc']; $params['thc_min'] = $settings['min_thc'];
} }
if (!empty($settings['max_price'])) { if (!empty($settings['max_price'])) {
$params['max_price'] = $settings['max_price']; $params['price_max'] = $settings['max_price'];
} }
// Only show in-stock by default // Only show in-stock by default
$params['in_stock_only'] = 'true'; $params['in_stock'] = 'true';
if (!empty($settings['limit'])) { if (!empty($settings['limit'])) {
$params['limit'] = $settings['limit']; $params['limit'] = $settings['limit'];
@@ -814,29 +821,28 @@ class CannaIQ_Product_Loop_Widget extends \Elementor\Widget_Base {
// Handle sorting // Handle sorting
$sort_map = [ $sort_map = [
'name' => ['sort_by' => 'name', 'sort_dir' => 'asc'], 'name' => ['sort' => 'name', 'order' => 'asc'],
'name_desc' => ['sort_by' => 'name', 'sort_dir' => 'desc'], 'name_desc' => ['sort' => 'name', 'order' => 'desc'],
'price' => ['sort_by' => 'price', 'sort_dir' => 'asc'], 'price' => ['sort' => 'price', 'order' => 'asc'],
'price_desc' => ['sort_by' => 'price', 'sort_dir' => 'desc'], 'price_desc' => ['sort' => 'price', 'order' => 'desc'],
'thc' => ['sort_by' => 'thc', 'sort_dir' => 'asc'], 'thc' => ['sort' => 'thc', 'order' => 'asc'],
'thc_desc' => ['sort_by' => 'thc', 'sort_dir' => 'desc'], 'thc_desc' => ['sort' => 'thc', 'order' => 'desc'],
'brand' => ['sort_by' => 'name', 'sort_dir' => 'asc'], 'brand' => ['sort' => 'brand', 'order' => 'asc'],
]; ];
if (!empty($settings['sort_by']) && isset($sort_map[$settings['sort_by']])) { if (!empty($settings['sort_by']) && isset($sort_map[$settings['sort_by']])) {
$params['sort_by'] = $sort_map[$settings['sort_by']]['sort_by']; $params['sort'] = $sort_map[$settings['sort_by']]['sort'];
$params['sort_dir'] = $sort_map[$settings['sort_by']]['sort_dir']; $params['order'] = $sort_map[$settings['sort_by']]['order'];
} }
$query_string = http_build_query($params); $query_string = http_build_query($params);
$url = $api_url . ($query_string ? '?' . $query_string : ''); $url = $api_url . ($query_string ? '?' . $query_string : '');
// Make request with API key header // Make request
$response = wp_remote_get($url, [ $response = wp_remote_get($url, [
'timeout' => 30, 'timeout' => 30,
'headers' => [ 'headers' => [
'Accept' => 'application/json', 'Accept' => 'application/json',
'X-API-Key' => $api_key,
], ],
]); ]);

View File

@@ -14,7 +14,7 @@ class CannaIQ_Menus_Single_Product_Widget extends \Elementor\Widget_Base {
} }
public function get_title() { public function get_title() {
return __('CannaiQ Single Product', 'cannaiq-menus'); return __('CannaIQ Single Product', 'cannaiq-menus');
} }
public function get_icon() { public function get_icon() {

View File

@@ -14,7 +14,7 @@ class CannaIQ_Menus_Specials_Grid_Widget extends \Elementor\Widget_Base {
} }
public function get_title() { public function get_title() {
return __('CannaiQ Specials/Deals', 'cannaiq-menus'); return __('CannaIQ Specials/Deals', 'cannaiq-menus');
} }
public function get_icon() { public function get_icon() {

View File

@@ -1,258 +0,0 @@
<?php
/**
* CannaIQ Stock Indicator Widget
*
* Displays product stock status (In Stock / Out of Stock).
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Stock_Indicator_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_stock_indicator';
}
public function get_title() {
return __('Stock Indicator', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-check-circle';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['stock', 'inventory', 'available', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'source',
[
'label' => __('Stock Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom value', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_in_stock',
[
'label' => __('In Stock', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => 'yes',
'condition' => [
'source' => 'custom',
],
]
);
$this->add_control(
'format',
[
'label' => __('Display Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'badge',
'options' => [
'badge' => __('Badge', 'cannaiq-menus'),
'text' => __('Text', 'cannaiq-menus'),
'dot' => __('Dot + Text', 'cannaiq-menus'),
],
]
);
$this->add_control(
'in_stock_text',
[
'label' => __('In Stock Text', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'In Stock',
]
);
$this->add_control(
'out_of_stock_text',
[
'label' => __('Out of Stock Text', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'Out of Stock',
]
);
$this->add_control(
'show_quantity',
[
'label' => __('Show Quantity', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => '',
'description' => __('Show quantity if available', 'cannaiq-menus'),
]
);
$this->add_control(
'hide_if_in_stock',
[
'label' => __('Hide if In Stock', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => '',
'description' => __('Only show when out of stock', '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(
'in_stock_color',
[
'label' => __('In Stock Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#16a34a',
]
);
$this->add_control(
'out_of_stock_color',
[
'label' => __('Out of Stock Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#9ca3af',
]
);
$this->add_control(
'in_stock_bg',
[
'label' => __('In Stock Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#dcfce7',
'condition' => [
'format' => 'badge',
],
]
);
$this->add_control(
'out_of_stock_bg',
[
'label' => __('Out of Stock Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f3f4f6',
'condition' => [
'format' => 'badge',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'typography',
'selector' => '{{WRAPPER}} .cannaiq-stock-indicator',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Get stock status
$in_stock = true;
$quantity = null;
if ($settings['source'] === 'custom') {
$in_stock = $settings['custom_in_stock'] === 'yes';
} else {
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$status = $cannaiq_current_product['Status'] ?? '';
$in_stock = ($status === 'Active' || $status === 'In Stock' || !empty($cannaiq_current_product['in_stock']));
// Get quantity if available
$quantity = $cannaiq_current_product['POSMetaData']['children'][0]['quantity']
?? $cannaiq_current_product['quantity']
?? null;
}
}
// Hide if in stock and setting enabled
if ($in_stock && $settings['hide_if_in_stock'] === 'yes') {
return;
}
// Determine display text
$text = $in_stock ? $settings['in_stock_text'] : $settings['out_of_stock_text'];
if ($in_stock && $settings['show_quantity'] === 'yes' && $quantity !== null) {
$text .= ' (' . intval($quantity) . ')';
}
// Colors
$color = $in_stock ? $settings['in_stock_color'] : $settings['out_of_stock_color'];
$bg_color = $in_stock ? $settings['in_stock_bg'] : $settings['out_of_stock_bg'];
// Build classes
$classes = [
'cannaiq-stock-indicator',
$in_stock ? 'cannaiq-stock-indicator--in-stock' : 'cannaiq-stock-indicator--out-of-stock',
];
if ($settings['format'] === 'badge') {
$classes[] = 'cannaiq-stock-indicator--badge';
}
// Build style
$style = sprintf('color: %s;', esc_attr($color));
if ($settings['format'] === 'badge') {
$style .= sprintf(' background-color: %s;', esc_attr($bg_color));
}
?>
<span class="<?php echo esc_attr(implode(' ', $classes)); ?>" style="<?php echo esc_attr($style); ?>">
<?php if ($settings['format'] === 'dot'): ?>
<span class="cannaiq-stock-indicator__dot"></span>
<?php endif; ?>
<?php echo esc_html($text); ?>
</span>
<?php
}
}

View File

@@ -1,250 +0,0 @@
<?php
/**
* CannaIQ Strain Badge Widget
*
* Displays strain type (Sativa/Indica/Hybrid) as a colored badge.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Strain_Badge_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_strain_badge';
}
public function get_title() {
return __('Strain Badge', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-tags';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['strain', 'sativa', 'indica', 'hybrid', 'badge', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'source',
[
'label' => __('Strain Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom value', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_strain',
[
'label' => __('Strain Type', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'hybrid',
'options' => [
'sativa' => __('Sativa', 'cannaiq-menus'),
'indica' => __('Indica', 'cannaiq-menus'),
'hybrid' => __('Hybrid', 'cannaiq-menus'),
],
'condition' => [
'source' => 'custom',
],
]
);
$this->add_control(
'format',
[
'label' => __('Display Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'pill',
'options' => [
'pill' => __('Pill', 'cannaiq-menus'),
'text' => __('Text Only', 'cannaiq-menus'),
],
]
);
$this->add_control(
'text_format',
[
'label' => __('Text Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'uppercase',
'options' => [
'uppercase' => __('UPPERCASE', 'cannaiq-menus'),
'capitalize' => __('Capitalize', 'cannaiq-menus'),
'lowercase' => __('lowercase', 'cannaiq-menus'),
],
]
);
$this->add_control(
'show_icon',
[
'label' => __('Show Icon', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'label_on' => __('Yes', 'cannaiq-menus'),
'label_off' => __('No', 'cannaiq-menus'),
'return_value' => 'yes',
'default' => '',
]
);
$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(
'size',
[
'label' => __('Size', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'medium',
'options' => [
'small' => __('Small', 'cannaiq-menus'),
'medium' => __('Medium', 'cannaiq-menus'),
'large' => __('Large', 'cannaiq-menus'),
],
]
);
$this->add_control(
'sativa_color',
[
'label' => __('Sativa Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#22c55e',
]
);
$this->add_control(
'indica_color',
[
'label' => __('Indica Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#8b5cf6',
]
);
$this->add_control(
'hybrid_color',
[
'label' => __('Hybrid Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f97316',
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'typography',
'selector' => '{{WRAPPER}} .cannaiq-strain-badge',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Get strain type
$strain = '';
if ($settings['source'] === 'custom') {
$strain = $settings['custom_strain'];
} else {
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
$strain = strtolower($cannaiq_current_product['strainType'] ?? $cannaiq_current_product['strain_type'] ?? '');
}
}
if (empty($strain)) {
return;
}
// Normalize strain type
$strain_key = strtolower($strain);
if (!in_array($strain_key, ['sativa', 'indica', 'hybrid'])) {
return;
}
// Format text
switch ($settings['text_format']) {
case 'uppercase':
$text = strtoupper($strain);
break;
case 'lowercase':
$text = strtolower($strain);
break;
default:
$text = ucfirst($strain);
}
// Get color based on strain type
$color = $settings[$strain_key . '_color'];
// Build classes
$classes = [
'cannaiq-strain-badge',
'cannaiq-strain-badge--' . $settings['format'],
'cannaiq-strain-badge--' . $strain_key,
];
if ($settings['size'] !== 'medium') {
$classes[] = 'cannaiq-strain-badge--' . $settings['size'];
}
// Build style
$style = '';
if ($settings['format'] === 'pill') {
$style = sprintf('background-color: %s; color: white;', esc_attr($color));
} else {
$style = sprintf('color: %s;', esc_attr($color));
}
// Icon SVG (leaf icon)
$icon = '';
if ($settings['show_icon'] === 'yes') {
$icon = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>';
}
printf(
'<span class="%s" style="%s">%s%s</span>',
esc_attr(implode(' ', $classes)),
esc_attr($style),
$icon,
esc_html($text)
);
}
}

View File

@@ -1,295 +0,0 @@
<?php
/**
* CannaIQ THC/CBD Meter Widget
*
* Displays THC or CBD percentage as a visual meter/progress bar.
*
* @package CannaIQ_Menus
* @since 2.0.0
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_THC_Meter_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq_thc_meter';
}
public function get_title() {
return __('THC/CBD Meter', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-skill-bar';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['thc', 'cbd', 'potency', 'meter', 'percentage', 'cannaiq'];
}
protected function register_controls() {
$this->start_controls_section(
'content_section',
[
'label' => __('Content', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'type',
[
'label' => __('Type', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'thc',
'options' => [
'thc' => __('THC', 'cannaiq-menus'),
'cbd' => __('CBD', 'cannaiq-menus'),
],
]
);
$this->add_control(
'source',
[
'label' => __('Value Source', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'auto',
'options' => [
'auto' => __('Auto (from product)', 'cannaiq-menus'),
'custom' => __('Custom value', 'cannaiq-menus'),
],
]
);
$this->add_control(
'custom_value',
[
'label' => __('Percentage', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 20,
'min' => 0,
'max' => 100,
'step' => 0.1,
'condition' => [
'source' => 'custom',
],
]
);
$this->add_control(
'display_format',
[
'label' => __('Display Format', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'meter',
'options' => [
'meter' => __('Meter (progress bar)', 'cannaiq-menus'),
'badge' => __('Badge', 'cannaiq-menus'),
'pill' => __('Pill', 'cannaiq-menus'),
'text' => __('Text Only', 'cannaiq-menus'),
],
]
);
$this->add_control(
'show_label',
[
'label' => __('Show Label', '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(
'max_percentage',
[
'label' => __('Max Percentage (for meter)', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 35,
'min' => 10,
'max' => 100,
'description' => __('Used to calculate bar fill percentage', 'cannaiq-menus'),
'condition' => [
'display_format' => 'meter',
],
]
);
$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(
'thc_color',
[
'label' => __('THC Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#22c55e',
]
);
$this->add_control(
'cbd_color',
[
'label' => __('CBD Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#3b82f6',
]
);
$this->add_control(
'bar_height',
[
'label' => __('Bar Height', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px'],
'range' => [
'px' => [
'min' => 4,
'max' => 20,
],
],
'default' => [
'size' => 6,
],
'selectors' => [
'{{WRAPPER}} .cannaiq-potency-meter__bar' => 'height: {{SIZE}}{{UNIT}};',
],
'condition' => [
'display_format' => 'meter',
],
]
);
$this->add_control(
'bar_background',
[
'label' => __('Bar Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#e5e7eb',
'selectors' => [
'{{WRAPPER}} .cannaiq-potency-meter__bar' => 'background-color: {{VALUE}};',
],
'condition' => [
'display_format' => 'meter',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'typography',
'selector' => '{{WRAPPER}} .cannaiq-potency-meter, {{WRAPPER}} .cannaiq-potency-badge',
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$type = $settings['type'];
// Get percentage value
$percentage = 0;
if ($settings['source'] === 'custom') {
$percentage = floatval($settings['custom_value']);
} else {
global $cannaiq_current_product;
if (isset($cannaiq_current_product)) {
if ($type === 'thc') {
$percentage = $cannaiq_current_product['THCContent']['range'][0]
?? $cannaiq_current_product['THC']
?? $cannaiq_current_product['thc_percentage']
?? 0;
} else {
$percentage = $cannaiq_current_product['CBDContent']['range'][0]
?? $cannaiq_current_product['CBD']
?? $cannaiq_current_product['cbd_percentage']
?? 0;
}
}
}
$percentage = floatval($percentage);
if ($percentage <= 0) {
return;
}
$label = strtoupper($type);
$color = $type === 'thc' ? $settings['thc_color'] : $settings['cbd_color'];
$formatted_value = number_format($percentage, 1) . '%';
switch ($settings['display_format']) {
case 'meter':
$fill_percent = min(100, ($percentage / floatval($settings['max_percentage'])) * 100);
?>
<div class="cannaiq-potency-meter cannaiq-potency-meter--<?php echo esc_attr($type); ?>">
<?php if ($settings['show_label'] === 'yes'): ?>
<div class="cannaiq-potency-meter__header">
<span class="cannaiq-potency-meter__label"><?php echo esc_html($label); ?></span>
<span class="cannaiq-potency-meter__value"><?php echo esc_html($formatted_value); ?></span>
</div>
<?php endif; ?>
<div class="cannaiq-potency-meter__bar">
<div class="cannaiq-potency-meter__fill" style="width: <?php echo esc_attr($fill_percent); ?>%; background: linear-gradient(90deg, <?php echo esc_attr($color); ?> 0%, <?php echo esc_attr($color); ?> 100%);"></div>
</div>
</div>
<?php
break;
case 'badge':
?>
<span class="cannaiq-potency-badge cannaiq-potency-badge--badge">
<?php if ($settings['show_label'] === 'yes'): ?>
<span class="cannaiq-potency-badge__label"><?php echo esc_html($label); ?></span>
<?php endif; ?>
<span class="cannaiq-potency-badge__value"><?php echo esc_html($formatted_value); ?></span>
</span>
<?php
break;
case 'pill':
?>
<span class="cannaiq-potency-badge cannaiq-potency-badge--pill" style="background-color: <?php echo esc_attr($color); ?>;">
<?php if ($settings['show_label'] === 'yes'): ?>
<?php echo esc_html($label); ?>:
<?php endif; ?>
<?php echo esc_html($formatted_value); ?>
</span>
<?php
break;
case 'text':
?>
<span class="cannaiq-potency-badge cannaiq-potency-badge--text">
<?php if ($settings['show_label'] === 'yes'): ?>
<?php echo esc_html($label); ?>:
<?php endif; ?>
<?php echo esc_html($formatted_value); ?>
</span>
<?php
break;
}
}
}