Compare commits

..

58 Commits

Author SHA1 Message Date
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
46 changed files with 7345 additions and 360 deletions

View File

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

View File

@@ -1,6 +1,6 @@
# Build stage
# 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)
RUN apt-get update && apt-get install -y \
@@ -27,7 +27,7 @@ RUN npm run build
RUN npm prune --production
# Production stage
FROM node:20-slim
FROM node:22-slim
# Build arguments for version info
ARG APP_BUILD_VERSION=dev

Binary file not shown.

View File

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

View File

@@ -151,18 +151,6 @@ function generateSlug(name: string, city: string, state: string): string {
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
@@ -415,7 +403,7 @@ async function promoteLocation(
loc.timezone, // $15 timezone
loc.platform_location_id, // $16 platform_dispensary_id
loc.platform_menu_url, // $17 menu_url
deriveMenuType(loc.platform_menu_url), // $18 menu_type
'dutchie', // $18 menu_type
loc.description, // $19 description
loc.logo_image, // $20 logo_image
loc.banner_image, // $21 banner_image

View File

@@ -105,6 +105,7 @@ import { createSystemRouter, createPrometheusRouter } from './system/routes';
import { createPortalRoutes } from './portals';
import { createStatesRouter } from './routes/states';
import { createAnalyticsV2Router } from './routes/analytics-v2';
import { createBrandsRouter } from './routes/brands';
import { createDiscoveryRoutes } from './discovery';
import pipelineRoutes from './routes/pipeline';
@@ -229,6 +230,15 @@ try {
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.)
// Uses dutchie_az data pipeline with per-dispensary API key auth
app.use('/api/v1', publicApiRoutes);

View File

@@ -289,6 +289,102 @@ export function getStoreConfig(): TreezStoreConfig | null {
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)
// ============================================================
@@ -343,9 +439,15 @@ export async function fetchAllProducts(
// Wait for initial page load to trigger first API request
await sleep(3000);
// Check if we captured the store config
// Check if we captured the store config from network requests
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');
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,7 @@
*/
import { Router, Request, Response } from 'express';
import { authMiddleware } from '../auth/middleware';
import {
taskService,
TaskRole,
@@ -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;

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

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

View File

@@ -261,28 +261,24 @@ class TaskService {
}
/**
* Mark a task as completed with verification
* Returns true if completion was verified in DB, false otherwise
* Mark a task as completed and remove from pool
* 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> {
await pool.query(
`UPDATE worker_tasks
SET status = 'completed', completed_at = NOW(), result = $2
WHERE id = $1`,
[taskId, result ? JSON.stringify(result) : null]
);
// Verify completion was recorded
const verify = await pool.query(
`SELECT status FROM worker_tasks WHERE id = $1`,
// Delete the completed task from the pool
// Only failed tasks stay in the table for retry/review
const deleteResult = await pool.query(
`DELETE FROM worker_tasks WHERE id = $1 RETURNING id`,
[taskId]
);
if (verify.rows[0]?.status !== 'completed') {
console.error(`[TaskService] Task ${taskId} completion NOT VERIFIED - DB shows status: ${verify.rows[0]?.status}`);
if (deleteResult.rowCount === 0) {
console.error(`[TaskService] Task ${taskId} completion FAILED - task not found or already deleted`);
return false;
}
console.log(`[TaskService] Task ${taskId} completed and removed from pool`);
return true;
}
@@ -351,7 +347,7 @@ class TaskService {
* Hard failures: Auto-retry up to MAX_RETRIES with exponential backoff
*/
async failTask(taskId: number, errorMessage: string): Promise<boolean> {
const MAX_RETRIES = 3;
const MAX_RETRIES = 5;
const isSoft = this.isSoftFailure(errorMessage);
// Get current retry count
@@ -490,7 +486,15 @@ class TaskService {
${poolJoin}
LEFT JOIN worker_registry w ON w.worker_id = t.worker_id
${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}`,
params
);
@@ -1001,9 +1005,31 @@ class TaskService {
const claimedAt = task.claimed_at || task.created_at;
switch (task.role) {
case 'product_refresh':
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(
`SELECT id, product_count, fetched_at
FROM raw_crawl_payloads

View File

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

View File

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

View File

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

View File

@@ -383,9 +383,10 @@ function PreflightSummary({ worker, poolOpen = true }: { worker: Worker; poolOpe
const fingerprint = worker.fingerprint_data;
const httpError = worker.preflight_http_error;
const httpMs = worker.preflight_http_ms;
// Geo from current_city/state columns, or fallback to fingerprint detected location
const geoState = worker.current_state || fingerprint?.detectedLocation?.region;
const geoCity = worker.current_city || fingerprint?.detectedLocation?.city;
// Show DETECTED proxy location (from fingerprint), not assigned state
// This lets us verify the proxy is geo-targeted correctly
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
const hasGeo = Boolean(geoState);
const isQualified = (worker.is_qualified || httpStatus === 'passed') && hasGeo;
@@ -702,8 +703,9 @@ function WorkerSlot({
const httpIp = worker?.http_ip;
const fingerprint = worker?.fingerprint_data;
const geoState = worker?.current_state || (fingerprint as any)?.detectedLocation?.region;
const geoCity = worker?.current_city || (fingerprint as any)?.detectedLocation?.city;
// Show DETECTED proxy location (from fingerprint), not assigned state
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;
// Build fingerprint tooltip

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
FROM node:20-slim AS builder
FROM node:22-slim AS builder
WORKDIR /app

View File

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

View File

@@ -5,12 +5,29 @@ metadata:
namespace: cannaiq
type: Opaque
stringData:
# PostgreSQL (external: 10.100.7.50 - primary)
POSTGRES_USER: "cannaiq"
POSTGRES_PASSWORD: "SpDyCannaIQ2024"
POSTGRES_DB: "cannaiq"
DATABASE_URL: "postgresql://cannaiq:SpDyCannaIQ2024@10.100.6.50:5432/cannaiq"
JWT_SECRET: "aW7vN3xKpM9qLsT2fB5jDc8hR4wY6zXe"
DATABASE_URL: "postgresql://cannaiq:SpDyCannaIQ2024@10.100.7.50:5432/cannaiq"
# 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_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_PASS: "ogh9U1Xe7Gzxzozo4rmP"

View File

@@ -1 +1 @@
1.7.0
2.0.0

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,10 +1,10 @@
<?php
/**
* Plugin Name: CannaIQ Menus
* Plugin Name: CannaiQ Menus
* Plugin URI: https://cannaiq.co
* Description: Display cannabis product menus from CannaIQ with Elementor integration. Real-time menu data updated daily.
* Version: 1.7.0
* Author: CannaIQ
* Description: Display cannabis product menus from CannaiQ with Elementor integration. Real-time menu data updated daily.
* Version: 2.0.0
* Author: CannaiQ
* Author URI: https://cannaiq.co
* License: GPL v2 or later
* Text Domain: cannaiq-menus
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
exit; // Exit if accessed directly
}
define('CANNAIQ_MENUS_VERSION', '1.7.0');
define('CANNAIQ_MENUS_VERSION', '2.0.0');
define('CANNAIQ_MENUS_API_URL', 'https://cannaiq.co/api/v1');
define('CANNAIQ_MENUS_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('CANNAIQ_MENUS_PLUGIN_URL', plugin_dir_url(__FILE__));
@@ -50,7 +50,7 @@ class CannaIQ_Menus_Plugin {
$elements_manager->add_category(
'cannaiq',
[
'title' => __('CannaIQ', 'cannaiq-menus'),
'title' => __('CannaiQ', 'cannaiq-menus'),
'icon' => 'fa fa-cannabis',
]
);
@@ -60,9 +60,13 @@ class CannaIQ_Menus_Plugin {
// Initialize plugin
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)
if (did_action('elementor/loaded')) {
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
@@ -78,6 +82,7 @@ class CannaIQ_Menus_Plugin {
* Register Elementor Widgets
*/
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/single-product.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/brand-grid.php';
@@ -85,18 +90,46 @@ class CannaIQ_Menus_Plugin {
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/specials-grid.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';
// Register legacy widgets
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Single_Product_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Brand_Grid_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Category_List_Widget());
$widgets_manager->register(new \CannaIQ_Menus_Specials_Grid_Widget());
$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());
}
/**
* Enqueue Scripts and Styles
*/
public function enqueue_scripts() {
// Base styles
wp_enqueue_style(
'cannaiq-menus-styles',
CANNAIQ_MENUS_PLUGIN_URL . 'assets/css/cannaiq-menus.css',
@@ -104,6 +137,14 @@ class CannaIQ_Menus_Plugin {
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(
'cannaiq-menus-script',
CANNAIQ_MENUS_PLUGIN_URL . 'assets/js/cannaiq-menus.js',
@@ -118,8 +159,8 @@ class CannaIQ_Menus_Plugin {
*/
public function add_admin_menu() {
add_menu_page(
'CannaIQ Menus',
'CannaIQ Menus',
'CannaiQ Menus',
'CannaiQ Menus',
'manage_options',
'cannaiq-menus',
[$this, 'admin_page'],
@@ -147,9 +188,9 @@ class CannaIQ_Menus_Plugin {
public function admin_page() {
?>
<div class="wrap">
<h1>CannaIQ Menus Settings</h1>
<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>
<h1>CannaiQ Menus Settings</h1>
<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>
<form method="post" action="options.php">
<?php settings_fields('cannaiq_menus_settings'); ?>
@@ -162,7 +203,7 @@ class CannaIQ_Menus_Plugin {
<input type="password" id="cannaiq_api_token" name="cannaiq_api_token"
value="<?php echo esc_attr(get_option('cannaiq_api_token')); ?>"
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>
</tr>
</table>
@@ -300,10 +341,10 @@ class CannaIQ_Menus_Plugin {
</table>
<h3>Elementor Widgets</h3>
<p>If you have Elementor installed, you can use the CannaIQ widgets:</p>
<p>If you have Elementor installed, you can use the CannaiQ widgets:</p>
<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>CannaIQ Single Product</strong> - Display a single product card</li>
<li><strong>CannaiQ Product Grid</strong> - Display a grid of products with filtering options</li>
<li><strong>CannaiQ Single Product</strong> - Display a single product card</li>
</ul>
</div>
<?php

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() {
return __('CannaIQ Brand Grid', 'cannaiq-menus');
return __('CannaiQ Brand Grid', 'cannaiq-menus');
}
public function get_icon() {

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,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() {
return __('CannaIQ Category List', 'cannaiq-menus');
return __('CannaiQ Category List', 'cannaiq-menus');
}
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
$dynamic_tags_manager->register_group('cannaiq', [
'title' => __('CannaIQ Product', 'cannaiq-menus')
'title' => __('CannaiQ Product', 'cannaiq-menus')
]);
// 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() {
return __('CannaIQ Product Grid', 'cannaiq-menus');
return __('CannaiQ Product Grid', 'cannaiq-menus');
}
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() {
return __('CannaIQ Product Loop', 'cannaiq-menus');
return __('CannaiQ Product Loop', 'cannaiq-menus');
}
public function get_icon() {

View File

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

View File

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