Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754a46c56f |
@@ -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
194
CLAUDE.md
@@ -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 |
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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.';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
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
1550
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1 +1 @@
|
|||||||
cannaiq-menus-2.3.0.zip
|
cannaiq-menus-1.7.0.zip
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(`
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 ? {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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')} />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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"
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
2.3.0
|
1.7.0
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
@@ -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"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user