Compare commits
58 Commits
production
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33ed1cae9 | ||
|
|
38e7980cf4 | ||
|
|
887ce33b11 | ||
|
|
de239df314 | ||
|
|
6fcc64933a | ||
|
|
3488905ccc | ||
|
|
3ee09fbe84 | ||
|
|
7d65e0ae59 | ||
|
|
25f9118662 | ||
|
|
5c0de752af | ||
|
|
a90b10a1f7 | ||
|
|
75822ab67d | ||
|
|
df4d599478 | ||
|
|
4544718cad | ||
|
|
47da61ed71 | ||
|
|
e450d2e99e | ||
|
|
205a8b3159 | ||
|
|
8bd29d11bb | ||
|
|
4e7b3d2336 | ||
|
|
849123693a | ||
|
|
a1227f77b9 | ||
|
|
415e89a012 | ||
|
|
45844c6281 | ||
|
|
24c9586d81 | ||
|
|
f8d61446d5 | ||
|
|
0f859d1c75 | ||
|
|
52dc669782 | ||
|
|
2e47996354 | ||
|
|
f25d4eaf27 | ||
|
|
61a6be888c | ||
|
|
09c2b3a0e1 | ||
|
|
cec34198c7 | ||
|
|
3c10e07e45 | ||
|
|
3582c2e9e2 | ||
|
|
c6874977ee | ||
|
|
68430f5c22 | ||
|
|
ccefd325aa | ||
|
|
e119c5af53 | ||
|
|
e61224aaed | ||
|
|
7cf1b7643f | ||
|
|
74f813d68f | ||
|
|
f38f1024de | ||
|
|
358099c58a | ||
|
|
7fdcfc4fc4 | ||
|
|
541b461283 | ||
|
|
8f25cf10ab | ||
|
|
79e434212f | ||
|
|
600172eff6 | ||
|
|
4c12763fa1 | ||
|
|
2cb9a093f4 | ||
|
|
15ab40a820 | ||
|
|
2708fbe319 | ||
|
|
231d49e3e8 | ||
|
|
17defa046c | ||
|
|
d76a5fb3c5 | ||
|
|
f19fc59583 | ||
|
|
4c183c87a9 | ||
|
|
ffa05f89c4 |
159
.woodpecker.yml
159
.woodpecker.yml
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
backend/public/downloads/cannaiq-menus-2.0.0.zip
Normal file
BIN
backend/public/downloads/cannaiq-menus-2.0.0.zip
Normal file
Binary file not shown.
@@ -1 +1 @@
|
||||
cannaiq-menus-1.7.0.zip
|
||||
cannaiq-menus-2.0.0.zip
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
1281
backend/src/routes/brands.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
589
backend/src/services/analytics/SalesAnalyticsService.ts
Normal file
589
backend/src/services/analytics/SalesAnalyticsService.ts
Normal 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();
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
84
docs/DOCKER_REGISTRY.md
Normal 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
104
docs/SPDY_INFRASTRUCTURE.md
Normal 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 |
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
55
k8s/registry-sync-cronjob.yaml
Normal file
55
k8s/registry-sync-cronjob.yaml
Normal 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"
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.7.0
|
||||
2.0.0
|
||||
|
||||
740
wordpress-plugin/assets/css/components.css
Normal file
740
wordpress-plugin/assets/css/components.css
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
192
wordpress-plugin/includes/effects-icons.php
Normal file
192
wordpress-plugin/includes/effects-icons.php
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
510
wordpress-plugin/widgets/card-template-premium.php
Normal file
510
wordpress-plugin/widgets/card-template-premium.php
Normal 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
|
||||
}
|
||||
}
|
||||
303
wordpress-plugin/widgets/cart-button.php
Normal file
303
wordpress-plugin/widgets/cart-button.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
216
wordpress-plugin/widgets/discount-ribbon.php
Normal file
216
wordpress-plugin/widgets/discount-ribbon.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
793
wordpress-plugin/widgets/dynamic-tags-extended.php
Normal file
793
wordpress-plugin/widgets/dynamic-tags-extended.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
288
wordpress-plugin/widgets/effects-display.php
Normal file
288
wordpress-plugin/widgets/effects-display.php
Normal 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
|
||||
}
|
||||
}
|
||||
309
wordpress-plugin/widgets/price-block.php
Normal file
309
wordpress-plugin/widgets/price-block.php
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
390
wordpress-plugin/widgets/product-image-overlay.php
Normal file
390
wordpress-plugin/widgets/product-image-overlay.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
258
wordpress-plugin/widgets/stock-indicator.php
Normal file
258
wordpress-plugin/widgets/stock-indicator.php
Normal 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
|
||||
}
|
||||
}
|
||||
250
wordpress-plugin/widgets/strain-badge.php
Normal file
250
wordpress-plugin/widgets/strain-badge.php
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
295
wordpress-plugin/widgets/thc-meter.php
Normal file
295
wordpress-plugin/widgets/thc-meter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user