Compare commits

...

63 Commits

Author SHA1 Message Date
Kelly
e8e7261409 fix: TypeScript null check in StockStatusBadge
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Use != null to handle both null and undefined for optional daysUntilOOS prop

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2025-12-17 02:33:07 -07:00
Kelly
1d254238f3 chore: Bump plugin version to 2.0.1
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 02:04:06 -07:00
Kelly
0b4ed48d2f feat: Add premade card templates and click analytics
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
WordPress Plugin v2.0.0:
- Add Promo Banner widget (dark banner with deal text)
- Add Horizontal Product Row widget (wide list format)
- Add Category Card widget (image-based categories)
- Add Compact Card widget (dense grid layout)
- Add CannaiQAnalytics click tracking (tracks add_to_cart,
  product_view, promo_click, category_click events)
- Register cannaiq-templates Elementor category
- Fix branding: CannaiQAnalytics (not CannaIQAnalytics)

Backend:
- Add POST /api/analytics/click endpoint for WordPress plugin
- Accepts API token auth, records to product_click_events table
- Stores metadata: product_name, price, category, url, referrer

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 02:03:28 -07:00
Kelly
87da7625cd feat: WordPress plugin v2.0.0 - modular component library
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Add 14 new shortcodes for building custom product cards
- Add visual builder guide with Hot Lava example in admin page
- Add comprehensive component documentation
- Update branding to "CannaiQ" throughout
- Include layout shortcodes: specials, brands, categories
- Include component shortcodes: discount_badge, strain_badge,
  thc, cbd, effects, price, cart_button, stock, terpenes
- Add build steps and instructions for assembling cards

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 01:58:20 -07:00
Kelly
9f3bc8a843 fix: Worker task concurrency limit and inventory tracking
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Fix claim_task to enforce max 5 tasks per worker (was unlimited)
- Add session_task_count check before ANY claiming path
- Add triggers to auto-decrement count on task complete/release
- Update MAX_CONCURRENT_TASKS default from 3 to 5
- Update frontend fallback to show 5 task slots

- Add Wasabi S3 storage for payload archival
- Add inventory snapshots service (delta-only tracking)
- Add sales analytics views and routes
- Add high-frequency manager UI components
- Reset hardcoded AZ 5-minute intervals (use UI to configure)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 01:34:38 -07:00
Kelly
c33ed1cae9 feat: CannaiQ Menus WordPress Plugin v2.0.0 - Modular Component Library
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
New modular component widgets:
- Discount Ribbon (ribbon/pill/text styles)
- Strain Badge (Sativa/Indica/Hybrid colored pills)
- THC/CBD Meter (progress bars or badges)
- Effects Display (styled chips with icons)
- Price Block (original + sale price)
- Cart Button (styled CTA linking to menu)
- Stock Indicator (in/out of stock badges)
- Product Image + Badges (image with overlays)

New card template:
- Premium Product Card (ready-to-use template)

Extended dynamic tags (30+ total):
- Discount %, Strain Badge, THC/CBD Badge
- Effects Chips, Terpenes, Price Display
- Menu URL, Stock Status, and more

New files:
- assets/css/components.css
- includes/effects-icons.php (SVG icons)
- 10 new widget files
- dynamic-tags-extended.php

Branding updated to "CannaiQ" throughout.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 00:21:40 -07:00
Kelly
38e7980cf4 fix: Add missing imports and type annotations
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add authMiddleware import to tasks.ts
- Fix pool import in SalesAnalyticsService.ts
- Add type annotations to map callbacks

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 00:03:52 -07:00
Kelly
887ce33b11 fix: Add health probes to scraper deployment
- Add liveness probe (restarts pod if unresponsive)
- Add readiness probe (removes from service if not ready)
- Add resource limits (512Mi-2Gi memory, 250m-1000m CPU)
- Update CI to apply full manifest on deploy
- Increase frontend rollout timeout to 300s

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 23:06:22 -07:00
Kelly
de239df314 fix(k8s): update registry-sync to use registry.spdy.io
Use registry.spdy.io instead of internal IP for base image syncing.
Add library/busybox:latest to the sync list.
2025-12-16 22:17:44 -07:00
Kelly
6fcc64933a fix: Increase cannaiq-frontend rollout timeout to 300s
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Prevents false CI failures when rollout takes longer than 120s.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 22:06:00 -07:00
Kelly
3488905ccc fix: Delete completed tasks from pool instead of marking complete
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Completed tasks are now deleted from worker_tasks table.
Only failed tasks remain in the pool for retry/review.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 21:19:20 -07:00
Kelly
3ee09fbe84 feat: Treez SSR support, task improvements, worker geo display
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add SSR config extraction for Treez sites (BEST Dispensary)
- Increase MAX_RETRIES from 3 to 5 for task failures
- Update task list ordering: active > pending > failed > completed
- Show detected proxy location in worker dashboard (from fingerprint)
- Hardcode 'dutchie' menu_type in promotion.ts (remove deriveMenuType)
- Update provider display to show actual provider names

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 19:22:04 -07:00
Kelly
7d65e0ae59 fix: Use cannaiq namespace for deployments
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Revert namespace from dispensary-scraper to cannaiq
- Keep registry.spdy.io for image URLs (k8s nodes need HTTPS)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 16:37:50 -07:00
Kelly
25f9118662 fix: Use registry.spdy.io for k8s deployments
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Update kubectl set image commands to use HTTPS registry URL
- Fix namespace from cannaiq to dispensary-scraper
- Add guidance on when to use which registry URL

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 12:37:11 -07:00
Kelly
5c0de752af fix: Check inventory_snapshots for product_discovery output verification
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
raw_crawl_payloads only saved during baseline window (12:01-3:00 AM),
but inventory_snapshots are always saved. This caused product_discovery
tasks to fail verification outside the baseline window.
2025-12-16 10:20:48 -07:00
Kelly
a90b10a1f7 feat(k8s): Add daily registry sync cronjob for base images
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-16 09:49:36 -07:00
Kelly
75822ab67d docs: Add Docker registry cache instructions
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-16 09:34:55 -07:00
Kelly
df4d599478 chore: test CI after fixes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2025-12-16 09:22:53 -07:00
Kelly
4544718cad chore: trigger CI after DNS fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-16 09:21:13 -07:00
Kelly
47da61ed71 chore: trigger CI rebuild
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-16 09:19:58 -07:00
Kelly
e450d2e99e fix(ci): use local registry mirror instead of mirror.gcr.io
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Switch Kaniko registry-mirror from mirror.gcr.io to 10.100.9.70:5000
to pull base images from local registry instead of GCR.
2025-12-16 09:09:15 -07:00
Kelly
205a8b3159 chore: retry CI for visibility-events fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-16 08:56:59 -07:00
Kelly
8bd29d11bb fix: Use correct column names in visibility-events query
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Changed name -> name_raw and brand -> brand_name_raw to match
store_products table schema.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 02:21:01 -07:00
Kelly
4e7b3d2336 fix: Update DATABASE_URL to point to primary PostgreSQL server
Changed from 10.100.6.50 (secondary/replica in read-only mode) to
10.100.7.50 (primary) to fix read-only transaction errors.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 01:13:49 -07:00
Kelly
849123693a fix(ci): Use unquoted heredoc for kubeconfig token injection
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Changed heredoc from 'KUBEEOF' (quoted) to KUBEEOF (unquoted)
- This allows shell variable expansion of $K8S_TOKEN directly
- Removed sed replacement step that was failing due to YAML escaping issues
2025-12-15 21:55:52 -07:00
Kelly
a1227f77b9 chore: retry CI
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 21:53:00 -07:00
Kelly
415e89a012 chore: retry CI with k8s_token secret
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 21:26:06 -07:00
Kelly
45844c6281 ci: Embed kubeconfig, use k8s_token secret for token only 2025-12-15 21:19:26 -07:00
Kelly
24c9586d81 ci: Skip base64 - use raw kubeconfig in secret
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 21:09:54 -07:00
Kelly
f8d61446d5 chore: retry CI with correct kubeconfig
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 20:57:19 -07:00
Kelly
0f859d1c75 chore: retry CI after kubeconfig fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 20:43:40 -07:00
Kelly
52dc669782 ci: Remove clone/volume config (requires admin trust)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Woodpecker doesn't allow custom clone or volumes without elevated trust.
Kaniko layer caching (--cache-repo) still works (registry-based).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:16:18 -07:00
Kelly
2e47996354 ci: Add shallow git clone (depth: 1)
Only fetch latest commit instead of full history.
Reduces checkout time and bandwidth.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:05:33 -07:00
Kelly
f25d4eaf27 ci: Add npm and Docker layer caching
- PR steps: shared npm-cache volume for faster npm ci
- Docker builds: --cache-repo to local registry for layer caching
- Kaniko will reuse npm install layer when package.json unchanged

First build populates cache, subsequent builds much faster.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:04:15 -07:00
Kelly
61a6be888c ci: Consolidate back to 4 docker steps
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Remove separate build steps (didn't save time)
- Use original multi-stage Dockerfiles
- Delete unused Dockerfile.ci files
- 4 parallel docker builds + deploy

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 20:01:11 -07:00
Kelly
09c2b3a0e1 ci: Use node:22 instead of node:22-alpine for builds
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Alpine uses musl libc which breaks Rollup's native bindings.
Debian-based node:22 uses glibc and works correctly.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:56:48 -07:00
Kelly
cec34198c7 ci: Add slim Dockerfile.ci files for faster CI builds
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Add Dockerfile.ci for backend, cannaiq, findadispo, findagram
- Frontend Dockerfiles just copy pre-built assets to nginx
- Backend Dockerfile copies pre-built dist/node_modules
- Reduces Docker build time by doing npm ci/build in CI step

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 19:43:08 -07:00
Kelly
3c10e07e45 feat(ci): Push built images to local registry for faster K8s pulls
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Build images push to 10.100.9.70:5000/cannaiq/*
- Deploy pulls from local registry (no external network)
- Removed git.spdy.io registry auth (not needed for local)
- Added --insecure-registry for HTTP local registry
2025-12-15 19:16:16 -07:00
Kelly
3582c2e9e2 fix(k8s): Use external Postgres/Redis/MinIO services
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Update secrets.yaml with correct MinIO credentials
- Add Redis connection details
- Remove postgres.yaml (use external 10.100.6.50)
- Remove redis.yaml (use external 10.100.9.50)
2025-12-15 19:03:05 -07:00
Kelly
c6874977ee docs: Add spdy.io infrastructure credentials
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:59:18 -07:00
Kelly
68430f5c22 fix(ci): Use mirror.gcr.io as registry mirror for Kaniko
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:52:02 -07:00
Kelly
ccefd325aa fix(ci): Use hardcoded Woodpecker workspace path for Kaniko
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:49:56 -07:00
Kelly
e119c5af53 chore: trigger CI
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:44:24 -07:00
Kelly
e61224aaed fix(ci): Use CI_WORKSPACE for Kaniko context paths
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:42:33 -07:00
Kelly
7cf1b7643f feat(ci): Use local registry 10.100.9.70:5000 for base images
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:28:20 -07:00
Kelly
74f813d68f feat(ci): Switch to Kaniko for Docker builds (no daemon, better DNS)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:20:53 -07:00
Kelly
f38f1024de fix(docker): Use mirror.gcr.io in all Dockerfiles to avoid rate limits
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:19:18 -07:00
Kelly
358099c58a chore: trigger CI
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:12:41 -07:00
Kelly
7fdcfc4fc4 fix(ci): Use mirror.gcr.io to avoid Docker Hub rate limits
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:11:16 -07:00
Kelly
541b461283 fix(ci): Use public node:20 image for typecheck steps
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 18:08:46 -07:00
Kelly
8f25cf10ab chore: retry CI
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 17:06:42 -07:00
Kelly
79e434212f chore: retry CI
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 16:45:05 -07:00
Kelly
600172eff6 chore: retry CI
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is running
2025-12-15 15:51:40 -07:00
Kelly
4c12763fa1 chore: retry CI
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 13:18:53 -07:00
Kelly
2cb9a093f4 chore: retry CI
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 12:29:45 -07:00
Kelly
15ab40a820 chore: trigger CI build
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 12:14:14 -07:00
Kelly
2708fbe319 feat(brands): Add calculated tags with configurable thresholds
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Tags assigned per store:
- must_win: High-revenue store with room to grow SKUs
- at_risk: High OOS% (losing shelf presence)
- top_performer: High sales + good inventory management
- growth: Above-average velocity
- low_inventory: Low days on hand

Configurable via query params:
- ?must_win_max_skus=5
- ?at_risk_oos_pct=30
- ?top_performer_max_oos=15
- ?low_inventory_days=7

Response includes tag_thresholds showing applied values.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:06:44 -07:00
Kelly
231d49e3e8 feat(brands): Add margin estimation to stores/performance endpoint
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Add ?margin_pct query param (default 50% industry standard)
- Returns margin_pct and margin_est per store
- Includes margin_pct_assumed in response metadata

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 12:02:36 -07:00
Kelly
17defa046c feat(api): Add /api/brands/:brand/stores/performance endpoint
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Add comprehensive per-store performance endpoint for Cannabrands integration.
Returns all metrics in one call for easy merging with internal order data.

Response includes per store:
- active_skus, oos_skus, total_skus, oos_pct
- avg_daily_units (velocity from inventory deltas)
- avg_days_on_hand (stock / daily velocity)
- total_sales_est (units × price × days)
- lost_opportunity (OOS days × velocity × price)
- categories breakdown (JSON object)
- avg_price, total_stock

Query params: ?days=28&state=AZ&limit=100&offset=0

Matches Hoodie Analytics columns for Order Management view.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:57:38 -07:00
Kelly
d76a5fb3c5 feat(api): Add brand analytics API endpoints
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Add comprehensive brand-level analytics endpoints at /api/brands:

Brand Discovery:
- GET /api/brands - List all brands with summary metrics
- GET /api/brands/search - Search brands by name
- GET /api/brands/top - Top brands by distribution

Brand Overview:
- GET /api/brands/:brand - Full brand intelligence dashboard
- GET /api/brands/:brand/analytics - Alias for overview

Sales & Velocity:
- GET /api/brands/:brand/sales - Sales data (4wk, daily avg)
- GET /api/brands/:brand/velocity - Units/day by SKU
- GET /api/brands/:brand/trends - Weekly sales trends

Inventory & Stock:
- GET /api/brands/:brand/inventory - Current stock levels
- GET /api/brands/:brand/oos - Out-of-stock products
- GET /api/brands/:brand/low-stock - Products below threshold

Pricing:
- GET /api/brands/:brand/pricing - Current prices
- GET /api/brands/:brand/price-history - Price changes over time

Distribution:
- GET /api/brands/:brand/distribution - Store count, market coverage
- GET /api/brands/:brand/stores - Stores carrying brand
- GET /api/brands/:brand/gaps - Whitespace opportunities

Events & Alerts:
- GET /api/brands/:brand/events - Visibility events
- POST /api/brands/:brand/events/:id/ack - Acknowledge alert

Products:
- GET /api/brands/:brand/products - All SKUs with metrics
- GET /api/brands/:brand/products/:sku - Single product deep dive

All endpoints support ?state=XX, ?days=N, and ?category=X filters.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 11:06:23 -07:00
Kelly
f19fc59583 chore: retry CI
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is running
2025-12-15 09:59:11 -07:00
Kelly
4c183c87a9 chore: retry CI after registry fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 09:35:05 -07:00
Kelly
ffa05f89c4 chore: trigger CI on develop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2025-12-15 09:06:43 -07:00
85 changed files with 17470 additions and 483 deletions

View File

@@ -3,7 +3,7 @@ steps:
# PR VALIDATION: Parallel type checks (PRs only) # PR VALIDATION: Parallel type checks (PRs only)
# =========================================== # ===========================================
typecheck-backend: typecheck-backend:
image: git.spdy.io/creationshop/node:20 image: node:22
commands: commands:
- cd backend - cd backend
- npm ci --prefer-offline - npm ci --prefer-offline
@@ -13,7 +13,7 @@ steps:
event: pull_request event: pull_request
typecheck-cannaiq: typecheck-cannaiq:
image: git.spdy.io/creationshop/node:20 image: node:22
commands: commands:
- cd cannaiq - cd cannaiq
- npm ci --prefer-offline - npm ci --prefer-offline
@@ -23,7 +23,7 @@ steps:
event: pull_request event: pull_request
typecheck-findadispo: typecheck-findadispo:
image: git.spdy.io/creationshop/node:20 image: node:22
commands: commands:
- cd findadispo/frontend - cd findadispo/frontend
- npm ci --prefer-offline - npm ci --prefer-offline
@@ -33,7 +33,7 @@ steps:
event: pull_request event: pull_request
typecheck-findagram: typecheck-findagram:
image: git.spdy.io/creationshop/node:20 image: node:22
commands: commands:
- cd findagram/frontend - cd findagram/frontend
- npm ci --prefer-offline - npm ci --prefer-offline
@@ -68,114 +68,117 @@ steps:
event: pull_request event: pull_request
# =========================================== # ===========================================
# MASTER DEPLOY: Parallel Docker builds # DOCKER: Multi-stage builds with layer caching
# NOTE: cache_from/cache_to removed due to plugin bug splitting on commas
# =========================================== # ===========================================
docker-backend: docker-backend:
image: plugins/docker image: gcr.io/kaniko-project/executor:debug
settings: commands:
registry: git.spdy.io - /kaniko/executor
repo: git.spdy.io/creationshop/cannaiq --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/backend
tags: --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/backend/Dockerfile
- latest --destination=registry.spdy.io/cannaiq/backend:latest
- sha-${CI_COMMIT_SHA:0:8} --destination=registry.spdy.io/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8}
dockerfile: backend/Dockerfile --build-arg=APP_BUILD_VERSION=sha-${CI_COMMIT_SHA:0:8}
context: backend --build-arg=APP_GIT_SHA=${CI_COMMIT_SHA}
username: --build-arg=APP_BUILD_TIME=${CI_PIPELINE_CREATED}
from_secret: registry_username --cache=true
password: --cache-repo=registry.spdy.io/cannaiq/cache-backend
from_secret: registry_password --cache-ttl=168h
build_args:
- APP_BUILD_VERSION=sha-${CI_COMMIT_SHA:0:8}
- APP_GIT_SHA=${CI_COMMIT_SHA}
- APP_BUILD_TIME=${CI_PIPELINE_CREATED}
- CONTAINER_IMAGE_TAG=sha-${CI_COMMIT_SHA:0:8}
depends_on: [] depends_on: []
when: when:
branch: [master, develop] branch: [master, develop]
event: push event: push
docker-cannaiq: docker-cannaiq:
image: plugins/docker image: gcr.io/kaniko-project/executor:debug
settings: commands:
registry: git.spdy.io - /kaniko/executor
repo: git.spdy.io/creationshop/cannaiq-frontend --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/cannaiq
tags: --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/cannaiq/Dockerfile
- latest --destination=registry.spdy.io/cannaiq/frontend:latest
- sha-${CI_COMMIT_SHA:0:8} --destination=registry.spdy.io/cannaiq/frontend:sha-${CI_COMMIT_SHA:0:8}
dockerfile: cannaiq/Dockerfile --cache=true
context: cannaiq --cache-repo=registry.spdy.io/cannaiq/cache-cannaiq
username: --cache-ttl=168h
from_secret: registry_username
password:
from_secret: registry_password
depends_on: [] depends_on: []
when: when:
branch: [master, develop] branch: [master, develop]
event: push event: push
docker-findadispo: docker-findadispo:
image: plugins/docker image: gcr.io/kaniko-project/executor:debug
settings: commands:
registry: git.spdy.io - /kaniko/executor
repo: git.spdy.io/creationshop/findadispo-frontend --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findadispo/frontend
tags: --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findadispo/frontend/Dockerfile
- latest --destination=registry.spdy.io/cannaiq/findadispo:latest
- sha-${CI_COMMIT_SHA:0:8} --destination=registry.spdy.io/cannaiq/findadispo:sha-${CI_COMMIT_SHA:0:8}
dockerfile: findadispo/frontend/Dockerfile --cache=true
context: findadispo/frontend --cache-repo=registry.spdy.io/cannaiq/cache-findadispo
username: --cache-ttl=168h
from_secret: registry_username
password:
from_secret: registry_password
depends_on: [] depends_on: []
when: when:
branch: [master, develop] branch: [master, develop]
event: push event: push
docker-findagram: docker-findagram:
image: plugins/docker image: gcr.io/kaniko-project/executor:debug
settings: commands:
registry: git.spdy.io - /kaniko/executor
repo: git.spdy.io/creationshop/findagram-frontend --context=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findagram/frontend
tags: --dockerfile=/woodpecker/src/git.spdy.io/Creationshop/cannaiq/findagram/frontend/Dockerfile
- latest --destination=registry.spdy.io/cannaiq/findagram:latest
- sha-${CI_COMMIT_SHA:0:8} --destination=registry.spdy.io/cannaiq/findagram:sha-${CI_COMMIT_SHA:0:8}
dockerfile: findagram/frontend/Dockerfile --cache=true
context: findagram/frontend --cache-repo=registry.spdy.io/cannaiq/cache-findagram
username: --cache-ttl=168h
from_secret: registry_username
password:
from_secret: registry_password
depends_on: [] depends_on: []
when: when:
branch: [master, develop] branch: [master, develop]
event: push event: push
# =========================================== # ===========================================
# STAGE 3: Deploy and Run Migrations # DEPLOY: Pull from local registry
# =========================================== # ===========================================
deploy: deploy:
image: bitnami/kubectl:latest image: bitnami/kubectl:latest
environment: environment:
KUBECONFIG_CONTENT: K8S_TOKEN:
from_secret: kubeconfig_data from_secret: k8s_token
commands: commands:
- mkdir -p ~/.kube - mkdir -p ~/.kube
- echo "$KUBECONFIG_CONTENT" | tr -d '[:space:]' | base64 -d > ~/.kube/config - |
cat > ~/.kube/config << KUBEEOF
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkakNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTmpVM05UUTNPRE13SGhjTk1qVXhNakUwTWpNeU5qSXpXaGNOTXpVeE1qRXlNak15TmpJegpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTmpVM05UUTNPRE13V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFRWDRNdFJRTW5lWVJVV0s2cjZ3VEV2WjAxNnV4T3NUR3JJZ013TXVnNGwKajQ1bHZ6ZkM1WE1NY1pESnUxZ0t1dVJhVGxlb0xVOVJnSERIUUI4TUwzNTJvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVXIzNDZpNE42TFhzaEZsREhvSlU0CjJ1RjZseGN3Q2dZSUtvWkl6ajBFQXdJRFJ3QXdSQUlnVUtqdWRFQWJyS1JDVHROVXZTc1Rmb3FEaHFSeDM5MkYKTFFSVWlKK0hCVElDSUJqOFIxbG1zSnFSRkRHMEpwMGN4OG5ZZnFCaElRQzh6WWdRdTdBZmR4L3IKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo=
server: https://10.100.6.10:6443
name: spdy-k3s
contexts:
- context:
cluster: spdy-k3s
namespace: cannaiq
user: cannaiq-admin
name: cannaiq
current-context: cannaiq
users:
- name: cannaiq-admin
user:
token: $K8S_TOKEN
KUBEEOF
- chmod 600 ~/.kube/config - chmod 600 ~/.kube/config
# Deploy backend first # Apply manifests to ensure probes and resource limits are set
- kubectl set image deployment/scraper scraper=git.spdy.io/creationshop/cannaiq:sha-${CI_COMMIT_SHA:0:8} -n cannaiq - kubectl apply -f /woodpecker/src/git.spdy.io/Creationshop/cannaiq/k8s/scraper.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
# Note: Migrations run automatically at startup via auto-migrate - 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
# Deploy remaining services - kubectl set image deployment/scraper-worker worker=registry.spdy.io/cannaiq/backend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
# Resilience: ensure workers are scaled up if at 0 - kubectl set image deployment/cannaiq-frontend cannaiq-frontend=registry.spdy.io/cannaiq/frontend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- REPLICAS=$(kubectl get deployment scraper-worker -n cannaiq -o jsonpath='{.spec.replicas}'); if [ "$REPLICAS" = "0" ]; then echo "Scaling workers from 0 to 5"; kubectl scale deployment/scraper-worker --replicas=5 -n cannaiq; fi - 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/scraper-worker worker=git.spdy.io/creationshop/cannaiq: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/cannaiq-frontend cannaiq-frontend=git.spdy.io/creationshop/cannaiq-frontend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq - kubectl rollout status deployment/cannaiq-frontend -n cannaiq --timeout=300s
- kubectl set image deployment/findadispo-frontend findadispo-frontend=git.spdy.io/creationshop/findadispo-frontend:sha-${CI_COMMIT_SHA:0:8} -n cannaiq
- kubectl set image deployment/findagram-frontend findagram-frontend=git.spdy.io/creationshop/findagram-frontend: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

122
CLAUDE.md
View File

@@ -72,10 +72,10 @@ Batch everything, push once, wait for user feedback.
```bash ```bash
# CORRECT - scale pods (up to 8) # CORRECT - scale pods (up to 8)
kubectl scale deployment/scraper-worker -n dispensary-scraper --replicas=8 kubectl scale deployment/scraper-worker -n cannaiq --replicas=8
# WRONG - will cause OOM crashes # WRONG - will cause OOM crashes
kubectl set env deployment/scraper-worker -n dispensary-scraper MAX_CONCURRENT_TASKS=10 kubectl set env deployment/scraper-worker -n cannaiq MAX_CONCURRENT_TASKS=10
``` ```
**If K8s API returns ServiceUnavailable:** STOP IMMEDIATELY. Do not retry. The cluster is overloaded. **If K8s API returns ServiceUnavailable:** STOP IMMEDIATELY. Do not retry. The cluster is overloaded.
@@ -294,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 dispensary-scraper -o jsonpath='{.data.EVOMI_PASS}' | base64 -d kubectl get secret scraper-secrets -n cannaiq -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}`
@@ -373,6 +373,122 @@ curl -X POST http://localhost:3010/api/tasks/crawl-state/AZ \
--- ---
## Wasabi S3 Storage (Payload Archive)
Raw crawl payloads are archived to Wasabi S3 for long-term storage and potential reprocessing.
### Configuration
| Variable | Description | Default |
|----------|-------------|---------|
| `WASABI_ACCESS_KEY` | Wasabi access key ID | - |
| `WASABI_SECRET_KEY` | Wasabi secret access key | - |
| `WASABI_BUCKET` | Bucket name | `cannaiq` |
| `WASABI_REGION` | Wasabi region | `us-west-2` |
| `WASABI_ENDPOINT` | S3 endpoint URL | `https://s3.us-west-2.wasabisys.com` |
### Storage Path Format
```
payloads/{state}/{YYYY-MM-DD}/{dispensary_id}/{platform}_{timestamp}.json.gz
```
Example: `payloads/AZ/2025-12-16/123/dutchie_2025-12-16T10-30-00-000Z.json.gz`
### Features
- **Gzip compression**: ~70% size reduction on JSON payloads
- **Automatic archival**: Every crawl is archived (not just daily baselines)
- **Metadata**: taskId, productCount, platform stored with each object
- **Graceful fallback**: If Wasabi not configured, archival is skipped (no task failure)
### Files
| File | Purpose |
|------|---------|
| `src/services/wasabi-storage.ts` | S3 client and storage functions |
| `src/tasks/handlers/product-discovery-dutchie.ts` | Archives Dutchie payloads |
| `src/tasks/handlers/product-discovery-jane.ts` | Archives Jane payloads |
| `src/tasks/handlers/product-discovery-treez.ts` | Archives Treez payloads |
### K8s Secret Setup
```bash
kubectl patch secret scraper-secrets -n cannaiq -p '{"stringData":{
"WASABI_ACCESS_KEY": "<access-key>",
"WASABI_SECRET_KEY": "<secret-key>"
}}'
```
### Usage in Code
```typescript
import { storePayload, getPayload, listPayloads } from '../services/wasabi-storage';
// Store a payload
const result = await storePayload(dispensaryId, 'AZ', 'dutchie', rawPayload);
console.log(result.path); // payloads/AZ/2025-12-16/123/dutchie_...
console.log(result.compressedBytes); // Size after gzip
// Retrieve a payload
const payload = await getPayload(result.path);
// List payloads for a store on a date
const paths = await listPayloads(123, 'AZ', '2025-12-16');
```
### Estimated Storage
- ~100KB per crawl (compressed)
- ~200 stores × 12 crawls/day = 240MB/day
- ~7.2GB/month
- 5TB capacity = ~5+ years of storage
---
## Real-Time Inventory Tracking
High-frequency crawling for sales velocity and inventory analytics.
### Crawl Intervals
| State | Interval | Jitter | Effective Range |
|-------|----------|--------|-----------------|
| AZ | 5 min | ±3 min | 2-8 min |
| Others | 60 min | ±3 min | 57-63 min |
### Delta-Only Snapshots
Only store inventory changes, not full state. Reduces storage by ~95%.
**Change Types**:
- `sale`: quantity decreased (qty_delta < 0)
- `restock`: quantity increased (qty_delta > 0)
- `price_change`: price changed, quantity same
- `oos`: went out of stock (qty → 0)
- `back_in_stock`: returned to stock (0 → qty)
- `new_product`: first time seeing product
### Revenue Calculation
```
revenue = ABS(qty_delta) × effective_price
effective_price = sale_price if on_special else regular_price
```
### Key Views
| View | Purpose |
|------|---------|
| `v_hourly_sales` | Sales aggregated by hour |
| `v_daily_store_sales` | Daily revenue by store |
| `v_daily_brand_sales` | Daily brand performance |
| `v_product_velocity` | Hot/steady/slow/stale rankings |
| `v_stock_out_prediction` | Days until OOS based on velocity |
| `v_brand_variants` | SKU counts per brand |
### Files
| File | Purpose |
|------|---------|
| `src/services/inventory-snapshots.ts` | Delta calculation and storage |
| `src/services/task-scheduler.ts` | High-frequency scheduling with jitter |
| `migrations/125_delta_only_snapshots.sql` | Delta columns and views |
| `migrations/126_az_high_frequency.sql` | AZ 5-min intervals |
---
## Documentation ## Documentation
| Doc | Purpose | | Doc | Purpose |

View File

@@ -1,6 +1,6 @@
# Build stage # Build stage
# Image: git.spdy.io/creationshop/dispensary-scraper # Image: git.spdy.io/creationshop/dispensary-scraper
FROM node:20-slim AS builder FROM node:22-slim AS builder
# Install build tools for native modules (bcrypt, sharp) # Install build tools for native modules (bcrypt, sharp)
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@@ -27,7 +27,7 @@ RUN npm run build
RUN npm prune --production RUN npm prune --production
# Production stage # Production stage
FROM node:20-slim FROM node:22-slim
# Build arguments for version info # Build arguments for version info
ARG APP_BUILD_VERSION=dev ARG APP_BUILD_VERSION=dev

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,164 @@
-- 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;

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

File diff suppressed because it is too large Load Diff

1550
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
"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.

View File

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

View File

@@ -151,18 +151,6 @@ 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
@@ -415,7 +403,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
deriveMenuType(loc.platform_menu_url), // $18 menu_type 'dutchie', // $18 menu_type
loc.description, // $19 description loc.description, // $19 description
loc.logo_image, // $20 logo_image loc.logo_image, // $20 logo_image
loc.banner_image, // $21 banner_image loc.banner_image, // $21 banner_image

View File

@@ -105,6 +105,7 @@ import { createSystemRouter, createPrometheusRouter } from './system/routes';
import { createPortalRoutes } from './portals'; import { createPortalRoutes } from './portals';
import { createStatesRouter } from './routes/states'; import { createStatesRouter } from './routes/states';
import { createAnalyticsV2Router } from './routes/analytics-v2'; import { createAnalyticsV2Router } from './routes/analytics-v2';
import { createBrandsRouter } from './routes/brands';
import { createDiscoveryRoutes } from './discovery'; import { createDiscoveryRoutes } from './discovery';
import pipelineRoutes from './routes/pipeline'; import pipelineRoutes from './routes/pipeline';
@@ -229,6 +230,15 @@ try {
console.warn('[AnalyticsV2] Failed to register routes:', error); console.warn('[AnalyticsV2] Failed to register routes:', error);
} }
// Brand Analytics API - Hoodie Analytics-style market intelligence
try {
const brandsRouter = createBrandsRouter(getPool());
app.use('/api/brands', brandsRouter);
console.log('[Brands] Routes registered at /api/brands');
} catch (error) {
console.warn('[Brands] Failed to register routes:', error);
}
// Public API v1 - External consumer endpoints (WordPress, etc.) // Public API v1 - External consumer endpoints (WordPress, etc.)
// Uses dutchie_az data pipeline with per-dispensary API key auth // Uses dutchie_az data pipeline with per-dispensary API key auth
app.use('/api/v1', publicApiRoutes); app.use('/api/v1', publicApiRoutes);
@@ -256,6 +266,11 @@ console.log('[ClickAnalytics] Routes registered at /api/analytics/clicks');
app.use('/api/analytics/price', priceAnalyticsRoutes); app.use('/api/analytics/price', priceAnalyticsRoutes);
console.log('[PriceAnalytics] Routes registered at /api/analytics/price'); console.log('[PriceAnalytics] Routes registered at /api/analytics/price');
// Sales Analytics API - sales velocity, brand market share, product intelligence
import salesAnalyticsRoutes from './routes/sales-analytics';
app.use('/api/sales-analytics', salesAnalyticsRoutes);
console.log('[SalesAnalytics] Routes registered at /api/sales-analytics');
// States API routes - cannabis legalization status and targeting // States API routes - cannabis legalization status and targeting
try { try {
const statesRouter = createStatesRouter(getPool()); const statesRouter = createStatesRouter(getPool());

View File

@@ -289,6 +289,102 @@ 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,
};
}
}
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)
// ============================================================ // ============================================================
@@ -343,9 +439,15 @@ 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 // Check if we captured the store config from network requests
if (!currentStoreConfig) { if (!currentStoreConfig) {
console.error('[Treez Client] Failed to capture store config from browser requests'); console.log('[Treez Client] No API requests captured - trying SSR fallback...');
// 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');
} }

1281
backend/src/routes/brands.ts Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,18 @@
/** /**
* Inventory Snapshots Service * Inventory Snapshots Service (Delta-Only)
* *
* Shared utility for saving lightweight inventory snapshots after each crawl. * Only stores snapshots when something CHANGES (quantity, price, status).
* Normalizes fields across all platforms (Dutchie, Jane, Treez) into a * This reduces storage by ~95% while capturing all meaningful events.
* common format for sales velocity tracking and analytics.
* *
* Part of Real-Time Inventory Tracking feature. * Part of Real-Time Inventory Tracking feature.
* *
* Field mappings: * Change types:
* | Field | Dutchie | Jane | Treez | * - sale: quantity decreased (qty_delta < 0)
* |-----------|------------------------|--------------------|------------------| * - restock: quantity increased (qty_delta > 0)
* | ID | id | product_id | id | * - price_change: price changed but quantity same
* | Quantity | children.quantityAvailable | max_cart_quantity | availableUnits | * - oos: went out of stock (quantity -> 0)
* | Low stock | isBelowThreshold | false | !isAboveThreshold| * - back_in_stock: came back in stock (0 -> quantity)
* | Price rec | recPrices[0] | bucket_price | customMinPrice | * - new_product: first time seeing this product
* | 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';
@@ -31,11 +26,37 @@ 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.
*/ */
@@ -46,6 +67,9 @@ 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;
@@ -75,6 +99,15 @@ 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;
} }
@@ -83,20 +116,24 @@ 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'; // Jane products present = active status = 'Active';
isBelowThreshold = false; // Jane doesn't expose this isBelowThreshold = false;
// 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; // Jane doesn't separate med prices clearly priceMed = null;
// Jane sale prices
if (product.discounted_price && priceRec && product.discounted_price < priceRec) {
priceRecSpecial = product.discounted_price;
isOnSpecial = true;
}
break; break;
} }
@@ -107,15 +144,17 @@ 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; // Treez doesn't distinguish med pricing priceMed = null;
// Treez sale prices
if (product.customOnSaleValue && priceRec && product.customOnSaleValue < priceRec) {
priceRecSpecial = product.customOnSaleValue;
isOnSpecial = true;
}
break; break;
} }
} }
@@ -131,6 +170,9 @@ 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,
@@ -138,61 +180,223 @@ function normalizeProduct(product: any, platform: Platform): SnapshotRow | null
} }
/** /**
* Save inventory snapshots for all products in a crawl result. * Determine if product state changed and calculate deltas
* */
* Call this after fetching products in any platform handler. function calculateDelta(
* Uses bulk insert for efficiency. current: SnapshotRow,
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 Number of snapshots saved * @returns Object with counts: { total, changed, sales, restocks }
*/ */
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<number> { ): Promise<{ total: number; changed: number; sales: number; restocks: number; revenue: number }> {
if (!products || products.length === 0) { if (!products || products.length === 0) {
return 0; return { total: 0, changed: 0, sales: 0, restocks: 0, revenue: 0 };
} }
const snapshots: SnapshotRow[] = []; const now = new Date();
// 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 row = normalizeProduct(product, platform); const normalized = normalizeProduct(product, platform);
if (row) { if (!normalized) continue;
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 (snapshots.length === 0) { if (deltas.length === 0) {
return 0; return { total: products.length, changed: 0, sales: 0, restocks: 0, revenue: 0 };
} }
// Bulk insert using VALUES list // Bulk insert deltas
// Build parameterized query
const values: any[] = []; const values: any[] = [];
const placeholders: string[] = []; const placeholders: string[] = [];
let paramIndex = 1; let paramIndex = 1;
for (const s of snapshots) { for (const d of deltas) {
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,
s.product_id, d.product_id,
platform, platform,
s.quantity_available, d.quantity_available,
s.is_below_threshold, d.is_below_threshold,
s.status, d.status,
s.price_rec, d.price_rec,
s.price_med, d.price_med,
s.brand_name, d.brand_name,
s.category, d.category,
s.product_name d.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
); );
} }
@@ -208,45 +412,71 @@ 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 snapshots.length; return {
total: products.length,
changed: deltas.length,
sales: salesCount,
restocks: restockCount,
revenue: Math.round(totalRevenue * 100) / 100,
};
} }
/** /**
* Get the previous snapshot for a dispensary (for delta calculation). * Get snapshot statistics for a dispensary
* Returns a map of product_id -> snapshot data.
*/ */
export async function getPreviousSnapshots( export async function getSnapshotStats(
pool: Pool, pool: Pool,
dispensaryId: number dispensaryId: number,
): Promise<Map<string, SnapshotRow>> { hours: number = 24
): Promise<{
totalSnapshots: number;
sales: number;
restocks: number;
priceChanges: number;
oosEvents: number;
revenue: number;
}> {
const result = await pool.query( const result = await pool.query(
` `
SELECT DISTINCT ON (product_id) SELECT
product_id, COUNT(*) as total,
quantity_available, COUNT(*) FILTER (WHERE change_type = 'sale') as sales,
is_below_threshold, COUNT(*) FILTER (WHERE change_type = 'restock') as restocks,
status, COUNT(*) FILTER (WHERE change_type = 'price_change') as price_changes,
price_rec, COUNT(*) FILTER (WHERE change_type = 'oos') as oos_events,
price_med, COALESCE(SUM(revenue_rec), 0) + COALESCE(SUM(revenue_med), 0) as revenue
brand_name,
category,
product_name
FROM inventory_snapshots FROM inventory_snapshots
WHERE dispensary_id = $1 WHERE dispensary_id = $1
ORDER BY product_id, captured_at DESC AND captured_at >= NOW() - INTERVAL '1 hour' * $2
`, `,
[dispensaryId] [dispensaryId, hours]
); );
const map = new Map<string, SnapshotRow>(); const row = result.rows[0];
for (const row of result.rows) { return {
map.set(row.product_id, row); totalSnapshots: parseInt(row.total),
} sales: parseInt(row.sales),
return map; restocks: parseInt(row.restocks),
priceChanges: parseInt(row.price_changes),
oosEvents: parseInt(row.oos_events),
revenue: parseFloat(row.revenue) || 0,
};
} }

View File

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

View File

@@ -110,8 +110,8 @@ export async function detectVisibilityEvents(
` `
SELECT SELECT
provider_product_id as id, provider_product_id as id,
name, name_raw as name,
brand, brand_name_raw as brand,
price_rec as price price_rec as price
FROM store_products FROM store_products
WHERE dispensary_id = $1 WHERE dispensary_id = $1

View File

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

View File

@@ -26,6 +26,7 @@ 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';
@@ -367,9 +368,8 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
await ctx.heartbeat(); await ctx.heartbeat();
// ============================================================ // ============================================================
// STEP 5: Save daily baseline (full payload) if in window // STEP 5: Archive raw payload to Wasabi S3 (long-term storage)
// Daily baselines are saved once per day per store (12:01 AM - 3:00 AM) // Every crawl is archived for potential reprocessing
// 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,6 +381,37 @@ 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,
@@ -395,7 +426,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 full payload save (outside baseline window or already exists)`); console.log(`[ProductDiscoveryHTTP] Skipped PostgreSQL baseline (outside window or already exists)`);
} }
// ============================================================ // ============================================================
@@ -459,6 +490,7 @@ export async function handleProductDiscoveryDutchie(ctx: TaskContext): Promise<T
productCount: result.products.length, productCount: result.products.length,
sizeBytes: payloadResult?.sizeBytes || 0, sizeBytes: payloadResult?.sizeBytes || 0,
baselineSaved: !!payloadResult, baselineSaved: !!payloadResult,
wasabiPath,
snapshotCount, snapshotCount,
eventCount, eventCount,
}; };

View File

@@ -19,6 +19,7 @@ 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;
@@ -36,7 +37,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 `SELECT id, name, menu_url, platform_dispensary_id, menu_type, state
FROM dispensaries WHERE id = $1`, FROM dispensaries WHERE id = $1`,
[dispensaryId] [dispensaryId]
); );
@@ -99,7 +100,32 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
storeId: dispensary.platform_dispensary_id, storeId: dispensary.platform_dispensary_id,
}; };
// Save daily baseline to filesystem (only in 12:01-3:00 AM window, once per day) // Archive to Wasabi S3 (if configured)
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,
@@ -113,7 +139,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 full payload save (outside baseline window or already exists)`); console.log(`[JaneProductDiscovery] Skipped PostgreSQL baseline (outside window or already exists)`);
} }
// Save inventory snapshots and detect visibility events // Save inventory snapshots and detect visibility events
@@ -155,6 +181,7 @@ export async function handleProductDiscoveryJane(ctx: TaskContext): Promise<Task
payloadId: payloadResult?.id || null, payloadId: payloadResult?.id || null,
payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0, payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0,
baselineSaved: !!payloadResult, baselineSaved: !!payloadResult,
wasabiPath,
snapshotCount, snapshotCount,
eventCount, eventCount,
storeInfo: result.store ? { storeInfo: result.store ? {

View File

@@ -33,6 +33,7 @@ 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;
@@ -50,7 +51,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 `SELECT id, name, menu_url, platform_dispensary_id, menu_type, platform, state
FROM dispensaries WHERE id = $1`, FROM dispensaries WHERE id = $1`,
[dispensaryId] [dispensaryId]
); );
@@ -116,7 +117,32 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
dispensaryId, dispensaryId,
}; };
// Save daily baseline to filesystem (only in 12:01-3:00 AM window, once per day) // Archive to Wasabi S3 (if configured)
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,
@@ -130,7 +156,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 full payload save (outside baseline window or already exists)`); console.log(`[TreezProductDiscovery] Skipped PostgreSQL baseline (outside window or already exists)`);
} }
// Save inventory snapshots and detect visibility events // Save inventory snapshots and detect visibility events
@@ -171,6 +197,7 @@ export async function handleProductDiscoveryTreez(ctx: TaskContext): Promise<Tas
payloadId: payloadResult?.id || null, payloadId: payloadResult?.id || null,
payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0, payloadSizeKB: payloadResult ? Math.round(payloadResult.sizeBytes / 1024) : 0,
baselineSaved: !!payloadResult, baselineSaved: !!payloadResult,
wasabiPath,
snapshotCount, snapshotCount,
eventCount, eventCount,
storeId: result.storeId, storeId: result.storeId,

View File

@@ -194,6 +194,21 @@ 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(
@@ -233,6 +248,15 @@ class TaskService {
RETURNING * RETURNING *
`, [workerId, curlPassed, httpPassed]); `, [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;
} }
@@ -261,28 +285,24 @@ class TaskService {
} }
/** /**
* Mark a task as completed with verification * Mark a task as completed and remove from pool
* Returns true if completion was verified in DB, false otherwise * Completed tasks are deleted - only failed tasks stay in the pool for retry/review
* 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> {
await pool.query( // Delete the completed task from the pool
`UPDATE worker_tasks // Only failed tasks stay in the table for retry/review
SET status = 'completed', completed_at = NOW(), result = $2 const deleteResult = await pool.query(
WHERE id = $1`, `DELETE FROM worker_tasks WHERE id = $1 RETURNING id`,
[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 (verify.rows[0]?.status !== 'completed') { if (deleteResult.rowCount === 0) {
console.error(`[TaskService] Task ${taskId} completion NOT VERIFIED - DB shows status: ${verify.rows[0]?.status}`); console.error(`[TaskService] Task ${taskId} completion FAILED - task not found or already deleted`);
return false; return false;
} }
console.log(`[TaskService] Task ${taskId} completed and removed from pool`);
return true; return true;
} }
@@ -351,7 +371,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 = 3; const MAX_RETRIES = 5;
const isSoft = this.isSoftFailure(errorMessage); const isSoft = this.isSoftFailure(errorMessage);
// Get current retry count // Get current retry count
@@ -490,7 +510,15 @@ 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 t.created_at DESC ORDER BY
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
); );
@@ -1001,9 +1029,31 @@ 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': {
// Verify payload was saved to raw_crawl_payloads after task was claimed // For product_discovery, verify inventory snapshots were saved (always happens)
// Note: raw_crawl_payloads only saved during baseline window, so check snapshots instead
const snapshotResult = await pool.query(
`SELECT COUNT(*)::int as count
FROM inventory_snapshots
WHERE dispensary_id = $1
AND captured_at > $2`,
[task.dispensary_id, claimedAt]
);
const snapshotCount = snapshotResult.rows[0]?.count || 0;
if (snapshotCount === 0) {
return {
verified: false,
reason: `No inventory snapshots found for dispensary ${task.dispensary_id} after ${claimedAt}`
};
}
return { verified: true };
}
case 'product_refresh': {
// For product_refresh, verify payload was saved to raw_crawl_payloads
const payloadResult = await pool.query( const payloadResult = await pool.query(
`SELECT id, product_count, fetched_at `SELECT id, product_count, fetched_at
FROM raw_crawl_payloads FROM raw_crawl_payloads

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:20-slim AS builder FROM node:22-slim AS builder
WORKDIR /app WORKDIR /app

View File

@@ -51,6 +51,9 @@ 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';
@@ -135,6 +138,10 @@ export default function App() {
<Route path="/payloads" element={<PrivateRoute><PayloadsDashboard /></PrivateRoute>} /> <Route path="/payloads" element={<PrivateRoute><PayloadsDashboard /></PrivateRoute>} />
{/* Scraper Overview Dashboard (new primary) */} {/* Scraper Overview Dashboard (new primary) */}
<Route path="/scraper/overview" element={<PrivateRoute><ScraperOverviewDashboard /></PrivateRoute>} /> <Route path="/scraper/overview" element={<PrivateRoute><ScraperOverviewDashboard /></PrivateRoute>} />
{/* Inventory Tracking routes */}
<Route path="/inventory/high-frequency" element={<PrivateRoute><HighFrequencyManager /></PrivateRoute>} />
<Route path="/inventory/events" element={<PrivateRoute><VisibilityEventsDashboard /></PrivateRoute>} />
<Route path="/inventory/snapshots" element={<PrivateRoute><InventorySnapshotsDashboard /></PrivateRoute>} />
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3231,6 +3231,399 @@ 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
@@ -3269,6 +3662,8 @@ export interface PayloadMetadata {
sizeBytesRaw: number; sizeBytesRaw: number;
fetchedAt: string; fetchedAt: string;
dispensary_name?: string; dispensary_name?: string;
city?: string;
state?: string;
} }
// Type for high-frequency (per-store) schedules // Type for high-frequency (per-store) schedules

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -383,9 +383,10 @@ 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;
// Geo from current_city/state columns, or fallback to fingerprint detected location // Show DETECTED proxy location (from fingerprint), not assigned state
const geoState = worker.current_state || fingerprint?.detectedLocation?.region; // This lets us verify the proxy is geo-targeted correctly
const geoCity = worker.current_city || fingerprint?.detectedLocation?.city; const geoState = fingerprint?.detectedLocation?.region || worker.current_state;
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;
@@ -702,8 +703,9 @@ function WorkerSlot({
const httpIp = worker?.http_ip; const httpIp = worker?.http_ip;
const fingerprint = worker?.fingerprint_data; const fingerprint = worker?.fingerprint_data;
const geoState = worker?.current_state || (fingerprint as any)?.detectedLocation?.region; // Show DETECTED proxy location (from fingerprint), not assigned state
const geoCity = worker?.current_city || (fingerprint as any)?.detectedLocation?.city; const geoState = (fingerprint as any)?.detectedLocation?.region || worker?.current_state;
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
@@ -803,7 +805,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 ?? 3; const maxSlots = worker?.max_concurrent_tasks ?? 5;
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;

84
docs/DOCKER_REGISTRY.md Normal file
View File

@@ -0,0 +1,84 @@
# 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.

104
docs/SPDY_INFRASTRUCTURE.md Normal file
View File

@@ -0,0 +1,104 @@
# CannaIQ Infrastructure (spdy.io)
External services for the spdy.io Kubernetes cluster. **Do not create containers for these.**
## PostgreSQL
| Setting | Value |
|----------|----------------------|
| Host | 10.100.6.50 |
| Port | 5432 |
| Database | cannaiq |
| Username | cannaiq |
| Password | SpDyCannaIQ2024 |
```bash
# Connection string
DATABASE_URL=postgres://cannaiq:SpDyCannaIQ2024@10.100.6.50:5432/cannaiq
# Test connection
PGPASSWORD='SpDyCannaIQ2024' psql -h 10.100.6.50 -p 5432 -U cannaiq -d cannaiq -c "SELECT 1"
```
## Redis
| Setting | Value |
|----------|----------------|
| Host | 10.100.9.50 |
| Port | 6379 |
| Password | SpDyR3d1s2024! |
```bash
# Connection URL
REDIS_URL=redis://:SpDyR3d1s2024!@10.100.9.50:6379
# Node.js .env
REDIS_HOST=10.100.9.50
REDIS_PORT=6379
REDIS_PASSWORD=SpDyR3d1s2024!
```
## MinIO (S3-Compatible Storage)
| Setting | Value |
|----------------|------------------|
| Endpoint | 10.100.9.80:9000 |
| Console | 10.100.9.80:9001 |
| Region | us-east-1 |
| Use Path Style | true |
### CannaIQ Bucket
| Setting | Value |
|------------|----------------|
| Bucket | cannaiq |
| Access Key | cannaiq-app |
| Secret Key | cannaiq-secret |
```bash
# Node.js .env
MINIO_ENDPOINT=10.100.9.80
MINIO_PORT=9000
MINIO_ACCESS_KEY=cannaiq-app
MINIO_SECRET_KEY=cannaiq-secret
MINIO_BUCKET=cannaiq
MINIO_USE_SSL=false
```
### Cannabrands Bucket
| Setting | Value |
|------------|------------------------------------------|
| Bucket | cannabrands |
| Access Key | cannabrands-app |
| Secret Key | cdbdcd0c7b6f3994d4ab09f68eaff98665df234f |
## Kubernetes Secrets
Create secrets in the `cannaiq` namespace:
```bash
# Database
kubectl create secret generic db-credentials -n cannaiq \
--from-literal=DATABASE_URL='postgres://cannaiq:SpDyCannaIQ2024@10.100.6.50:5432/cannaiq'
# Redis
kubectl create secret generic redis-credentials -n cannaiq \
--from-literal=REDIS_URL='redis://:SpDyR3d1s2024!@10.100.9.50:6379'
# MinIO
kubectl create secret generic minio-credentials -n cannaiq \
--from-literal=MINIO_ACCESS_KEY='cannaiq-app' \
--from-literal=MINIO_SECRET_KEY='cannaiq-secret'
```
## Network
All services are on the `10.100.x.x` internal network:
| Service | IP | Port |
|------------|--------------|------|
| PostgreSQL | 10.100.6.50 | 5432 |
| Redis | 10.100.9.50 | 6379 |
| MinIO | 10.100.9.80 | 9000 |
| Registry | 10.100.9.70 | 5000 |

View File

@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:20-slim AS builder FROM node:22-slim AS builder
WORKDIR /app WORKDIR /app

View File

@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:20-slim AS builder FROM node:22-slim AS builder
WORKDIR /app WORKDIR /app

View File

@@ -1,76 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: cannaiq
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: cannaiq
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:15-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: scraper-secrets
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: scraper-secrets
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: scraper-secrets
key: POSTGRES_DB
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/data
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: cannaiq
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432

View File

@@ -1,66 +0,0 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: cannaiq
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: cannaiq
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "256Mi"
cpu: "200m"
volumeMounts:
- name: redis-data
mountPath: /data
command:
- redis-server
- --appendonly
- "yes"
- --maxmemory
- "200mb"
- --maxmemory-policy
- allkeys-lru
volumes:
- name: redis-data
persistentVolumeClaim:
claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: cannaiq
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379

View File

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

View File

@@ -1,20 +1,10 @@
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:
@@ -25,27 +15,22 @@ spec:
labels: labels:
app: scraper app: scraper
spec: spec:
serviceAccountName: scraper-sa
imagePullSecrets: imagePullSecrets:
- name: regcred - name: gitea-registry
containers: containers:
- name: scraper - name: scraper
image: git.spdy.io/creationshop/cannaiq:latest image: registry.spdy.io/cannaiq/backend:latest
imagePullPolicy: Always
ports: ports:
- containerPort: 3010 - containerPort: 3000
envFrom: envFrom:
- configMapRef: - configMapRef:
name: scraper-config name: cannaiq-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: 3010 port: 3000
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
@@ -54,7 +39,7 @@ spec:
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /health path: /health
port: 3010 port: 3000
initialDelaySeconds: 10 initialDelaySeconds: 10
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 5 timeoutSeconds: 5
@@ -64,9 +49,5 @@ spec:
memory: "512Mi" memory: "512Mi"
cpu: "250m" cpu: "250m"
limits: limits:
memory: "1Gi" memory: "2Gi"
cpu: "1000m" cpu: "1000m"
volumes:
- name: images-storage
persistentVolumeClaim:
claimName: scraper-images-pvc

View File

@@ -5,12 +5,29 @@ metadata:
namespace: cannaiq namespace: cannaiq
type: Opaque type: Opaque
stringData: stringData:
# PostgreSQL (external: 10.100.7.50 - primary)
POSTGRES_USER: "cannaiq" POSTGRES_USER: "cannaiq"
POSTGRES_PASSWORD: "SpDyCannaIQ2024" POSTGRES_PASSWORD: "SpDyCannaIQ2024"
POSTGRES_DB: "cannaiq" POSTGRES_DB: "cannaiq"
DATABASE_URL: "postgresql://cannaiq:SpDyCannaIQ2024@10.100.6.50:5432/cannaiq" DATABASE_URL: "postgresql://cannaiq:SpDyCannaIQ2024@10.100.7.50:5432/cannaiq"
JWT_SECRET: "aW7vN3xKpM9qLsT2fB5jDc8hR4wY6zXe"
# Redis (external: 10.100.9.50)
REDIS_HOST: "10.100.9.50"
REDIS_PORT: "6379"
REDIS_PASSWORD: "SpDyR3d1s2024!"
REDIS_URL: "redis://:SpDyR3d1s2024!@10.100.9.50:6379"
# MinIO (external: 10.100.9.80)
MINIO_ENDPOINT: "10.100.9.80"
MINIO_PORT: "9000"
MINIO_ACCESS_KEY: "cannaiq-app" MINIO_ACCESS_KEY: "cannaiq-app"
MINIO_SECRET_KEY: "62a37268f2fe4163ef46fe1c29ad93f817b415fc" MINIO_SECRET_KEY: "cannaiq-secret"
MINIO_BUCKET: "cannaiq"
MINIO_USE_SSL: "false"
# Auth
JWT_SECRET: "aW7vN3xKpM9qLsT2fB5jDc8hR4wY6zXe"
# Evomi Proxy
EVOMI_USER: "kl8" EVOMI_USER: "kl8"
EVOMI_PASS: "ogh9U1Xe7Gzxzozo4rmP" EVOMI_PASS: "ogh9U1Xe7Gzxzozo4rmP"

View File

@@ -1 +1 @@
1.7.0 2.0.1

View File

@@ -0,0 +1,740 @@
/**
* 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;
}

View File

@@ -1,15 +1,118 @@
/** /**
* CannaIQ Menus - WordPress Plugin JavaScript * CannaIQ Menus - WordPress Plugin JavaScript
* v1.5.3 * v2.0.0
*/ */
(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 Add to Cart clicks
$(document).on('click', '.cannaiq-cart-button, .cannaiq-add-to-cart, .cannaiq-hr-add-btn, .cannaiq-cc-button, [class*="cannaiq"][href*="dutchie"], [class*="cannaiq"][href*="iheartjane"]', function(e) {
var $el = $(this);
var $card = $el.closest('[data-product-id], .cannaiq-product-card, .cannaiq-premium-card, .cannaiq-horizontal-row, .cannaiq-compact-card');
self.track('add_to_cart', {
product_id: $card.data('product-id') || $el.data('product-id'),
product_name: $card.data('product-name') || $el.data('product-name') || $card.find('.cannaiq-product-name, .cannaiq-premium-name, .cannaiq-hr-name, .cannaiq-cc-name').first().text().trim(),
product_price: $card.data('product-price') || $el.data('product-price'),
store_id: $card.data('store-id') || $el.data('store-id'),
category: $card.data('category') || $el.data('category'),
url: $el.attr('href')
});
});
// 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')
});
});
}
};
/** /**
* 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) => {
@@ -49,10 +152,13 @@
threshold: 0.1 threshold: 0.1
}); });
document.querySelectorAll('.cannaiq-product-card').forEach(card => { document.querySelectorAll('.cannaiq-product-card, .cannaiq-premium-card, .cannaiq-compact-card').forEach(card => {
cardObserver.observe(card); cardObserver.observe(card);
}); });
} }
}); });
// Expose for external use
window.CannaiQAnalytics = CannaiQAnalytics;
})(jQuery); })(jQuery);

View File

@@ -1,10 +1,10 @@
<?php <?php
/** /**
* Plugin Name: CannaIQ Menus * Plugin Name: CannaiQ Menus
* Plugin URI: https://cannaiq.co * Plugin URI: https://cannaiq.co
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily. * Description: Display cannabis product menus from CannaiQ with Elementor integration. Real-time menu data updated daily.
* Version: 1.7.0 * Version: 2.0.1
* 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', '1.7.0'); define('CANNAIQ_MENUS_VERSION', '2.0.1');
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,30 +44,55 @@ class CannaIQ_Menus_Plugin {
} }
/** /**
* Register CannaIQ Elementor Widget Category * Register CannaIQ Elementor Widget Categories
*/ */
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',
]
);
} }
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';
} }
// Register shortcodes - primary CannaIQ shortcodes // Register shortcodes - primary CannaiQ shortcodes
add_shortcode('cannaiq_products', [$this, 'products_shortcode']); add_shortcode('cannaiq_products', [$this, 'products_shortcode']);
add_shortcode('cannaiq_product', [$this, 'single_product_shortcode']); add_shortcode('cannaiq_product', [$this, 'single_product_shortcode']);
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']);
// 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']);
@@ -78,6 +103,7 @@ 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';
@@ -85,18 +111,54 @@ 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)
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';
// 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)
$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());
} }
/** /**
* 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',
@@ -104,6 +166,14 @@ 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',
@@ -111,6 +181,15 @@ 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),
]);
} }
/** /**
@@ -118,8 +197,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'],
@@ -147,9 +226,9 @@ class CannaIQ_Menus_Plugin {
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'); ?>
@@ -162,7 +241,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>
@@ -279,8 +358,9 @@ class CannaIQ_Menus_Plugin {
<hr /> <hr />
<h2>Usage</h2> <h2>Usage</h2>
<h3>Shortcodes</h3> <h3>Shortcodes</h3>
<table class="widefat" style="max-width: 800px;"> <table class="widefat" style="max-width: 900px;">
<thead> <thead>
<tr> <tr>
<th>Shortcode</th> <th>Shortcode</th>
@@ -290,21 +370,175 @@ class CannaIQ_Menus_Plugin {
<tbody> <tbody>
<tr> <tr>
<td><code>[cannaiq_products]</code></td> <td><code>[cannaiq_products]</code></td>
<td>Display a grid of products. Options: <code>category_id</code>, <code>limit</code>, <code>columns</code>, <code>in_stock</code></td> <td>Product grid. Options: <code>category</code>, <code>brand</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>Display a single product by ID</td> <td>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>
<h3>Elementor Widgets</h3> <h4 style="margin-top: 20px;">Component Shortcodes <span style="color: #666; font-weight: normal;">(use inside product context)</span></h4>
<p>If you have Elementor installed, you can use the CannaIQ widgets:</p> <table class="widefat" style="max-width: 900px;">
<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>CannaIQ Product Grid</strong> - Display a grid of products with filtering options</li> <li><strong>Product Grid</strong> - Filterable product grid</li>
<li><strong>CannaIQ Single Product</strong> - Display a single product card</li> <li><strong>Product Loop</strong> - Custom loop for building cards</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
} }
@@ -354,6 +588,321 @@ 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
*/
public function cart_button_shortcode($atts) {
global $cannaiq_current_product;
if (!$cannaiq_current_product) return '';
$atts = shortcode_atts(['text' => 'ADD TO CART', 'style' => 'solid'], $atts);
$url = $cannaiq_current_product['menuUrl'] ?? $cannaiq_current_product['menu_url'] ?? '#';
return sprintf('<a href="%s" class="cannaiq-cart-button cannaiq-cart-button--%s" target="_blank" rel="noopener">%s</a>',
esc_url($url), esc_attr($atts['style']), 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();
}
/** /**
* Fetch Products from API * Fetch Products from API
*/ */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,793 @@
<?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());
}, 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>';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,295 @@
<?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;
}
}
}