feat(plugin): Add Elementor dynamic tags and product loop widget v1.7.0

WordPress Plugin:
- Add dynamic tags for all product payload fields (name, brand, price, THC, effects, etc.)
- Add Product Loop widget with filtering, sorting, and layout options
- Register CannaIQ widget category in Elementor
- Update build script to auto-upload to MinIO CDN
- Remove legacy dutchie references
- Bump version to 1.7.0

Backend:
- Redirect /downloads/* to CDN instead of serving from local filesystem

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-13 03:10:01 -07:00
parent 9f0d68d4c9
commit 5c08135007
8 changed files with 1941 additions and 70 deletions

View File

@@ -0,0 +1,806 @@
<?php
/**
* CannaIQ Elementor Dynamic Tags
*
* Registers dynamic tags for all product payload fields.
* Users can insert these tags into any Elementor widget.
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Register CannaIQ dynamic tag group
*/
add_action('elementor/dynamic_tags/register', function($dynamic_tags_manager) {
// Register CannaIQ group
$dynamic_tags_manager->register_group('cannaiq', [
'title' => __('CannaIQ Product', 'cannaiq-menus')
]);
// Register all tags
$dynamic_tags_manager->register(new CannaIQ_Product_Name_Tag());
$dynamic_tags_manager->register(new CannaIQ_Product_Description_Tag());
$dynamic_tags_manager->register(new CannaIQ_Brand_Name_Tag());
$dynamic_tags_manager->register(new CannaIQ_Brand_Logo_Tag());
$dynamic_tags_manager->register(new CannaIQ_Brand_Description_Tag());
$dynamic_tags_manager->register(new CannaIQ_Category_Tag());
$dynamic_tags_manager->register(new CannaIQ_Price_Tag());
$dynamic_tags_manager->register(new CannaIQ_Price_Rec_Tag());
$dynamic_tags_manager->register(new CannaIQ_Price_Med_Tag());
$dynamic_tags_manager->register(new CannaIQ_THC_Tag());
$dynamic_tags_manager->register(new CannaIQ_CBD_Tag());
$dynamic_tags_manager->register(new CannaIQ_Strain_Type_Tag());
$dynamic_tags_manager->register(new CannaIQ_Effects_Tag());
$dynamic_tags_manager->register(new CannaIQ_Weight_Tag());
$dynamic_tags_manager->register(new CannaIQ_Image_Tag());
$dynamic_tags_manager->register(new CannaIQ_In_Stock_Tag());
$dynamic_tags_manager->register(new CannaIQ_On_Sale_Tag());
$dynamic_tags_manager->register(new CannaIQ_Sale_Percent_Tag());
$dynamic_tags_manager->register(new CannaIQ_Quantity_Tag());
});
/**
* Base class for CannaIQ dynamic tags
*/
abstract class CannaIQ_Dynamic_Tag_Base extends \Elementor\Core\DynamicTags\Tag {
public function get_group() {
return 'cannaiq';
}
/**
* Get the current product from context
*/
protected function get_current_product() {
global $cannaiq_current_product;
return $cannaiq_current_product ?? [];
}
/**
* Get nested value from array using dot notation
*/
protected function get_nested_value($array, $path, $default = '') {
$keys = explode('.', $path);
$value = $array;
foreach ($keys as $key) {
// Handle array index like [0]
if (preg_match('/^(\w+)\[(\d+)\]$/', $key, $matches)) {
$key = $matches[1];
$index = (int)$matches[2];
if (isset($value[$key][$index])) {
$value = $value[$key][$index];
} else {
return $default;
}
} else {
if (isset($value[$key])) {
$value = $value[$key];
} else {
return $default;
}
}
}
return $value;
}
}
/**
* Product Name Tag
*/
class CannaIQ_Product_Name_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-product-name';
}
public function get_title() {
return __('Product Name', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
echo esc_html($product['Name'] ?? '');
}
}
/**
* Product Description Tag
*/
class CannaIQ_Product_Description_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-product-description';
}
public function get_title() {
return __('Product Description', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
echo esc_html($product['description'] ?? '');
}
}
/**
* Brand Name Tag
*/
class CannaIQ_Brand_Name_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-brand-name';
}
public function get_title() {
return __('Brand Name', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
echo esc_html($product['brand']['name'] ?? $product['brandName'] ?? '');
}
}
/**
* Brand Logo Tag
*/
class CannaIQ_Brand_Logo_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-brand-logo';
}
public function get_title() {
return __('Brand Logo', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::IMAGE_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$url = $product['brand']['imageUrl'] ?? $product['brandLogo'] ?? '';
echo esc_url($url);
}
public function get_value(array $options = []) {
$product = $this->get_current_product();
$url = $product['brand']['imageUrl'] ?? $product['brandLogo'] ?? '';
return [
'id' => '',
'url' => $url,
];
}
}
/**
* Brand Description Tag
*/
class CannaIQ_Brand_Description_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-brand-description';
}
public function get_title() {
return __('Brand Description', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
echo esc_html($product['brand']['description'] ?? '');
}
}
/**
* Category Tag
*/
class CannaIQ_Category_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-category';
}
public function get_title() {
return __('Category', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
echo esc_html($product['type'] ?? $product['category'] ?? '');
}
}
/**
* Price Tag
*/
class CannaIQ_Price_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-price';
}
public function get_title() {
return __('Price', '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' => 'currency',
'options' => [
'currency' => '$XX.XX',
'number' => 'XX.XX',
'raw' => 'Raw value',
],
]);
}
public function render() {
$product = $this->get_current_product();
$price = $product['Prices'][0] ?? $product['recPrices'][0] ?? null;
$format = $this->get_settings('format');
if ($price === null) {
return;
}
switch ($format) {
case 'currency':
echo '$' . number_format((float)$price, 2);
break;
case 'number':
echo number_format((float)$price, 2);
break;
default:
echo esc_html($price);
}
}
}
/**
* Rec Price Tag
*/
class CannaIQ_Price_Rec_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-price-rec';
}
public function get_title() {
return __('Rec Price', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$price = $product['recPrices'][0] ?? $product['Prices'][0] ?? null;
if ($price !== null) {
echo '$' . number_format((float)$price, 2);
}
}
}
/**
* Med Price Tag
*/
class CannaIQ_Price_Med_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-price-med';
}
public function get_title() {
return __('Med Price', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$price = $product['medPrices'][0] ?? null;
// Try POSMetaData
if ($price === null && isset($product['POSMetaData']['children'][0]['medPrice'])) {
$price = $product['POSMetaData']['children'][0]['medPrice'];
}
if ($price !== null) {
echo '$' . number_format((float)$price, 2);
}
}
}
/**
* THC Tag
*/
class CannaIQ_THC_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-thc';
}
public function get_title() {
return __('THC %', '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' => 'percent',
'options' => [
'percent' => 'XX.X%',
'number' => 'XX.X',
'badge' => 'THC: XX.X%',
],
]);
}
public function render() {
$product = $this->get_current_product();
$thc = $product['THCContent']['range'][0] ?? $product['THC'] ?? null;
$format = $this->get_settings('format');
if ($thc === null) {
return;
}
switch ($format) {
case 'percent':
echo number_format((float)$thc, 1) . '%';
break;
case 'number':
echo number_format((float)$thc, 1);
break;
case 'badge':
echo 'THC: ' . number_format((float)$thc, 1) . '%';
break;
}
}
}
/**
* CBD Tag
*/
class CannaIQ_CBD_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-cbd';
}
public function get_title() {
return __('CBD %', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$cbd = $product['CBDContent']['range'][0] ?? $product['CBD'] ?? null;
if ($cbd !== null) {
echo number_format((float)$cbd, 1) . '%';
}
}
}
/**
* Strain Type Tag
*/
class CannaIQ_Strain_Type_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-strain-type';
}
public function get_title() {
return __('Strain Type', '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' => 'full',
'options' => [
'full' => 'Sativa / Indica / Hybrid',
'short' => 'S / I / H',
'uppercase' => 'SATIVA / INDICA / HYBRID',
],
]);
}
public function render() {
$product = $this->get_current_product();
$strain = $product['strainType'] ?? '';
$format = $this->get_settings('format');
if (empty($strain)) {
return;
}
switch ($format) {
case 'short':
echo strtoupper(substr($strain, 0, 1));
break;
case 'uppercase':
echo strtoupper($strain);
break;
default:
echo ucfirst($strain);
}
}
}
/**
* Effects Tag
*/
class CannaIQ_Effects_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-effects';
}
public function get_title() {
return __('Effects', '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' => 'badges',
'options' => [
'badges' => 'HTML Badges',
'list' => 'Comma separated',
'top3' => 'Top 3 effects',
],
]);
$this->add_control('limit', [
'label' => __('Max Effects', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 5,
'min' => 1,
'max' => 10,
]);
}
public function render() {
$product = $this->get_current_product();
$effects = $product['effects'] ?? [];
$format = $this->get_settings('format');
$limit = (int)$this->get_settings('limit') ?: 5;
if (empty($effects) || !is_array($effects)) {
return;
}
// Sort by value descending
arsort($effects);
// Take top N
$effects = array_slice($effects, 0, $limit, true);
switch ($format) {
case 'badges':
echo '<div class="cannaiq-effects-badges">';
foreach ($effects as $effect => $score) {
$icon = $this->get_effect_icon($effect);
echo '<span class="cannaiq-effect-badge cannaiq-effect-' . esc_attr($effect) . '">';
echo $icon . ' ' . esc_html(strtoupper($effect));
echo '</span>';
}
echo '</div>';
break;
case 'list':
$names = array_map('ucfirst', array_keys($effects));
echo esc_html(implode(', ', $names));
break;
case 'top3':
$names = array_slice(array_map('ucfirst', array_keys($effects)), 0, 3);
echo esc_html(implode(', ', $names));
break;
}
}
private function get_effect_icon($effect) {
$icons = [
'happy' => '😊',
'relaxed' => '😌',
'sleepy' => '😴',
'focused' => '🎯',
'creative' => '🎨',
'energetic' => '⚡',
'hungry' => '🍽️',
'euphoric' => '✨',
'uplifted' => '🌟',
'talkative' => '💬',
'inspired' => '💡',
'giggly' => '😄',
];
return $icons[strtolower($effect)] ?? '•';
}
}
/**
* Weight Tag
*/
class CannaIQ_Weight_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-weight';
}
public function get_title() {
return __('Weight', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$weight = $product['Options'][0] ?? $product['rawOptions'][0] ?? '';
echo esc_html($weight);
}
}
/**
* Product Image Tag
*/
class CannaIQ_Image_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-image';
}
public function get_title() {
return __('Product Image', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::IMAGE_CATEGORY];
}
public function render() {
$product = $this->get_current_product();
$url = $product['Image'] ?? $product['images'][0]['url'] ?? '';
echo esc_url($url);
}
public function get_value(array $options = []) {
$product = $this->get_current_product();
$url = $product['Image'] ?? $product['images'][0]['url'] ?? '';
return [
'id' => '',
'url' => $url,
];
}
}
/**
* In Stock Tag
*/
class CannaIQ_In_Stock_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-in-stock';
}
public function get_title() {
return __('In Stock', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$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',
]);
}
public function render() {
$product = $this->get_current_product();
$status = $product['Status'] ?? '';
$in_stock = ($status === 'Active' || $status === 'In Stock');
if ($in_stock) {
echo esc_html($this->get_settings('in_stock_text'));
} else {
echo esc_html($this->get_settings('out_of_stock_text'));
}
}
}
/**
* On Sale Tag
*/
class CannaIQ_On_Sale_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-on-sale';
}
public function get_title() {
return __('On Sale', 'cannaiq-menus');
}
public function get_categories() {
return [\Elementor\Modules\DynamicTags\Module::TEXT_CATEGORY];
}
protected function register_controls() {
$this->add_control('sale_text', [
'label' => __('Sale Text', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => 'SALE',
]);
}
public function render() {
$product = $this->get_current_product();
$on_sale = $product['special'] ?? false;
if ($on_sale) {
echo esc_html($this->get_settings('sale_text'));
}
}
}
/**
* Sale Percent Tag
*/
class CannaIQ_Sale_Percent_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-sale-percent';
}
public function get_title() {
return __('Sale Percentage', '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' => 'badge',
'options' => [
'badge' => 'XX% OFF',
'number' => 'XX',
'percent' => 'XX%',
],
]);
}
public function render() {
$product = $this->get_current_product();
// Calculate discount if we have original and sale price
$original = $product['Prices'][0] ?? null;
$sale = $product['specialPrice'] ?? null;
// Check POSMetaData for discount
if (isset($product['POSMetaData']['children'][0])) {
$child = $product['POSMetaData']['children'][0];
if (isset($child['price']) && isset($child['specialPrice'])) {
$original = $child['price'];
$sale = $child['specialPrice'];
}
}
if ($original && $sale && $original > $sale) {
$percent = round((($original - $sale) / $original) * 100);
$format = $this->get_settings('format');
switch ($format) {
case 'badge':
echo $percent . '% OFF';
break;
case 'number':
echo $percent;
break;
case 'percent':
echo $percent . '%';
break;
}
}
}
}
/**
* Quantity Tag
*/
class CannaIQ_Quantity_Tag extends CannaIQ_Dynamic_Tag_Base {
public function get_name() {
return 'cannaiq-quantity';
}
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();
$quantity = $product['POSMetaData']['children'][0]['quantity'] ?? null;
if ($quantity !== null) {
echo (int)$quantity;
}
}
}

View File

@@ -0,0 +1,858 @@
<?php
/**
* CannaIQ Product Loop Widget
*
* Elementor widget that loops through products and renders each one.
* Sets the global $cannaiq_current_product for each iteration so dynamic tags work.
*/
if (!defined('ABSPATH')) {
exit;
}
class CannaIQ_Product_Loop_Widget extends \Elementor\Widget_Base {
public function get_name() {
return 'cannaiq-product-loop';
}
public function get_title() {
return __('CannaIQ Product Loop', 'cannaiq-menus');
}
public function get_icon() {
return 'eicon-loop-builder';
}
public function get_categories() {
return ['cannaiq'];
}
public function get_keywords() {
return ['cannaiq', 'products', 'loop', 'menu', 'cannabis', 'dispensary'];
}
protected function register_controls() {
// Content Section - Data Source
$this->start_controls_section(
'section_data_source',
[
'label' => __('Data Source', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'dispensary_id',
[
'label' => __('Store ID', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => '',
'description' => __('Enter your store/dispensary ID from CannaIQ', 'cannaiq-menus'),
]
);
$this->add_control(
'limit',
[
'label' => __('Products Limit', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 12,
'min' => 1,
'max' => 100,
]
);
$this->end_controls_section();
// Content Section - Filters
$this->start_controls_section(
'section_filters',
[
'label' => __('Filters', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'category',
[
'label' => __('Category', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'options' => [
'' => __('All Categories', 'cannaiq-menus'),
'Flower' => __('Flower', 'cannaiq-menus'),
'Vaporizers' => __('Vaporizers', 'cannaiq-menus'),
'Concentrates' => __('Concentrates', 'cannaiq-menus'),
'Edibles' => __('Edibles', 'cannaiq-menus'),
'Pre-Rolls' => __('Pre-Rolls', 'cannaiq-menus'),
'Topicals' => __('Topicals', 'cannaiq-menus'),
'Tinctures' => __('Tinctures', 'cannaiq-menus'),
'Accessories' => __('Accessories', 'cannaiq-menus'),
],
]
);
$this->add_control(
'strain_type',
[
'label' => __('Strain Type', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '',
'options' => [
'' => __('All Strains', 'cannaiq-menus'),
'sativa' => __('Sativa', 'cannaiq-menus'),
'indica' => __('Indica', 'cannaiq-menus'),
'hybrid' => __('Hybrid', 'cannaiq-menus'),
'cbd' => __('CBD', 'cannaiq-menus'),
],
]
);
$this->add_control(
'brand',
[
'label' => __('Brand Name', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => '',
'description' => __('Filter by brand name (exact match)', 'cannaiq-menus'),
]
);
$this->add_control(
'on_sale',
[
'label' => __('On Sale Only', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => '',
]
);
$this->add_control(
'min_thc',
[
'label' => __('Minimum THC %', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => '',
'min' => 0,
'max' => 100,
]
);
$this->add_control(
'max_price',
[
'label' => __('Maximum Price', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => '',
'min' => 0,
]
);
$this->add_control(
'search',
[
'label' => __('Search Term', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::TEXT,
'default' => '',
'description' => __('Search in product name, brand, description', 'cannaiq-menus'),
]
);
$this->add_control(
'sort_by',
[
'label' => __('Sort By', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'name',
'options' => [
'name' => __('Name (A-Z)', 'cannaiq-menus'),
'name_desc' => __('Name (Z-A)', 'cannaiq-menus'),
'price' => __('Price (Low to High)', 'cannaiq-menus'),
'price_desc' => __('Price (High to Low)', 'cannaiq-menus'),
'thc' => __('THC % (Low to High)', 'cannaiq-menus'),
'thc_desc' => __('THC % (High to Low)', 'cannaiq-menus'),
'brand' => __('Brand (A-Z)', 'cannaiq-menus'),
],
]
);
$this->end_controls_section();
// Layout Section
$this->start_controls_section(
'section_layout',
[
'label' => __('Layout', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'layout',
[
'label' => __('Layout', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'grid',
'options' => [
'grid' => __('Grid', 'cannaiq-menus'),
'list' => __('List', 'cannaiq-menus'),
'masonry' => __('Masonry', 'cannaiq-menus'),
],
]
);
$this->add_responsive_control(
'columns',
[
'label' => __('Columns', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => '3',
'tablet_default' => '2',
'mobile_default' => '1',
'options' => [
'1' => '1',
'2' => '2',
'3' => '3',
'4' => '4',
'5' => '5',
'6' => '6',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-loop-grid' => 'grid-template-columns: repeat({{VALUE}}, 1fr);',
],
'condition' => [
'layout' => ['grid', 'masonry'],
],
]
);
$this->add_responsive_control(
'gap',
[
'label' => __('Gap', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', 'em', 'rem'],
'range' => [
'px' => ['min' => 0, 'max' => 100],
],
'default' => ['size' => 20, 'unit' => 'px'],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-loop-grid' => 'gap: {{SIZE}}{{UNIT}};',
'{{WRAPPER}} .cannaiq-product-loop-list' => 'gap: {{SIZE}}{{UNIT}};',
],
]
);
$this->end_controls_section();
// Card Display Section
$this->start_controls_section(
'section_card_display',
[
'label' => __('Card Display', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_CONTENT,
]
);
$this->add_control(
'show_image',
[
'label' => __('Show Image', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_brand',
[
'label' => __('Show Brand', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_name',
[
'label' => __('Show Name', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_category',
[
'label' => __('Show Category', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_strain_type',
[
'label' => __('Show Strain Type', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_thc',
[
'label' => __('Show THC %', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_cbd',
[
'label' => __('Show CBD %', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => '',
]
);
$this->add_control(
'show_effects',
[
'label' => __('Show Effects', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'effects_limit',
[
'label' => __('Effects Limit', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::NUMBER,
'default' => 3,
'min' => 1,
'max' => 10,
'condition' => [
'show_effects' => 'yes',
],
]
);
$this->add_control(
'show_price',
[
'label' => __('Show Price', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_weight',
[
'label' => __('Show Weight/Size', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->add_control(
'show_sale_badge',
[
'label' => __('Show Sale Badge', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SWITCHER,
'default' => 'yes',
]
);
$this->end_controls_section();
// Style Section - Card
$this->start_controls_section(
'section_style_card',
[
'label' => __('Card', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_control(
'card_background',
[
'label' => __('Background Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-card' => 'background-color: {{VALUE}};',
],
]
);
$this->add_responsive_control(
'card_padding',
[
'label' => __('Padding', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', 'em', '%'],
'default' => [
'top' => '15',
'right' => '15',
'bottom' => '15',
'left' => '15',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-card' => 'padding: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Border::get_type(),
[
'name' => 'card_border',
'selector' => '{{WRAPPER}} .cannaiq-product-card',
]
);
$this->add_control(
'card_border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', '%'],
'default' => [
'top' => '8',
'right' => '8',
'bottom' => '8',
'left' => '8',
'unit' => 'px',
],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-card' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Box_Shadow::get_type(),
[
'name' => 'card_shadow',
'selector' => '{{WRAPPER}} .cannaiq-product-card',
]
);
$this->end_controls_section();
// Style Section - Image
$this->start_controls_section(
'section_style_image',
[
'label' => __('Image', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_responsive_control(
'image_height',
[
'label' => __('Height', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SLIDER,
'size_units' => ['px', 'vh'],
'range' => [
'px' => ['min' => 100, 'max' => 500],
],
'default' => ['size' => 200, 'unit' => 'px'],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-image' => 'height: {{SIZE}}{{UNIT}};',
],
]
);
$this->add_control(
'image_fit',
[
'label' => __('Object Fit', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::SELECT,
'default' => 'contain',
'options' => [
'contain' => __('Contain', 'cannaiq-menus'),
'cover' => __('Cover', 'cannaiq-menus'),
'fill' => __('Fill', 'cannaiq-menus'),
],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-image img' => 'object-fit: {{VALUE}};',
],
]
);
$this->add_control(
'image_border_radius',
[
'label' => __('Border Radius', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::DIMENSIONS,
'size_units' => ['px', '%'],
'selectors' => [
'{{WRAPPER}} .cannaiq-product-image' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]
);
$this->end_controls_section();
// Style Section - Typography
$this->start_controls_section(
'section_style_typography',
[
'label' => __('Typography', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_control(
'name_color',
[
'label' => __('Name Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#1a1a1a',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-name' => 'color: {{VALUE}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'name_typography',
'label' => __('Name Typography', 'cannaiq-menus'),
'selector' => '{{WRAPPER}} .cannaiq-product-name',
]
);
$this->add_control(
'brand_color',
[
'label' => __('Brand Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#666666',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-brand' => 'color: {{VALUE}};',
],
]
);
$this->add_control(
'price_color',
[
'label' => __('Price Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#2ecc71',
'selectors' => [
'{{WRAPPER}} .cannaiq-product-price' => 'color: {{VALUE}};',
],
]
);
$this->add_group_control(
\Elementor\Group_Control_Typography::get_type(),
[
'name' => 'price_typography',
'label' => __('Price Typography', 'cannaiq-menus'),
'selector' => '{{WRAPPER}} .cannaiq-product-price',
]
);
$this->end_controls_section();
// Style Section - Badges
$this->start_controls_section(
'section_style_badges',
[
'label' => __('Badges', 'cannaiq-menus'),
'tab' => \Elementor\Controls_Manager::TAB_STYLE,
]
);
$this->add_control(
'strain_sativa_color',
[
'label' => __('Sativa Badge Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f39c12',
]
);
$this->add_control(
'strain_indica_color',
[
'label' => __('Indica Badge Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#9b59b6',
]
);
$this->add_control(
'strain_hybrid_color',
[
'label' => __('Hybrid Badge Color', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#2ecc71',
]
);
$this->add_control(
'sale_badge_color',
[
'label' => __('Sale Badge Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#e74c3c',
'selectors' => [
'{{WRAPPER}} .cannaiq-sale-badge' => 'background-color: {{VALUE}};',
],
]
);
$this->add_control(
'effect_badge_background',
[
'label' => __('Effect Badge Background', 'cannaiq-menus'),
'type' => \Elementor\Controls_Manager::COLOR,
'default' => '#f0f0f0',
'selectors' => [
'{{WRAPPER}} .cannaiq-effect-badge' => 'background-color: {{VALUE}};',
],
]
);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$products = $this->fetch_products($settings);
if (empty($products)) {
echo '<p class="cannaiq-no-products">' . __('No products found.', 'cannaiq-menus') . '</p>';
return;
}
$layout_class = 'cannaiq-product-loop-' . $settings['layout'];
?>
<div class="cannaiq-product-loop <?php echo esc_attr($layout_class); ?>">
<?php
global $cannaiq_current_product;
foreach ($products as $product) {
// Set global for dynamic tags
$cannaiq_current_product = $product;
$this->render_product_card($product, $settings);
}
// Clear global
$cannaiq_current_product = null;
?>
</div>
<?php
}
protected function render_product_card($product, $settings) {
// Support both normalized API format and raw payload format
$strain_type = strtolower($product['strain_type'] ?? $product['strainType'] ?? '');
$strain_colors = [
'sativa' => $settings['strain_sativa_color'],
'indica' => $settings['strain_indica_color'],
'hybrid' => $settings['strain_hybrid_color'],
];
$strain_color = $strain_colors[$strain_type] ?? '#999999';
// Get values supporting both formats
$product_id = $product['id'] ?? $product['_id'] ?? '';
$product_name = $product['name'] ?? $product['Name'] ?? '';
$brand_name = $product['brand'] ?? $product['brandName'] ?? '';
if (is_array($brand_name)) {
$brand_name = $brand_name['name'] ?? '';
}
$category = $product['category'] ?? $product['type'] ?? $product['Category'] ?? '';
$image_url = $product['image_url'] ?? $product['Image'] ?? $product['images'][0]['url'] ?? '';
$weight = $product['weight'] ?? $product['Options'][0] ?? $product['rawOptions'][0] ?? '';
$thc = $product['thc'] ?? $product['THCContent']['range'][0] ?? $product['THC'] ?? null;
$cbd = $product['cbd'] ?? $product['CBDContent']['range'][0] ?? $product['CBD'] ?? null;
$price = $product['price'] ?? $product['Prices'][0] ?? $product['recPrices'][0] ?? null;
if (is_array($price)) {
$price = $price['price'] ?? $price[0] ?? null;
}
$on_sale = $product['special'] ?? $product['on_sale'] ?? false;
$in_stock = $product['in_stock'] ?? ($product['status'] ?? $product['Status']) === 'Active';
?>
<div class="cannaiq-product-card" data-product-id="<?php echo esc_attr($product_id); ?>">
<?php if ($settings['show_sale_badge'] === 'yes' && $on_sale): ?>
<div class="cannaiq-sale-badge">SALE</div>
<?php endif; ?>
<?php if ($settings['show_image'] === 'yes'): ?>
<div class="cannaiq-product-image">
<?php if ($image_url): ?>
<img src="<?php echo esc_url($image_url); ?>" alt="<?php echo esc_attr($product_name); ?>" loading="lazy" />
<?php endif; ?>
</div>
<?php endif; ?>
<div class="cannaiq-product-content">
<?php if ($settings['show_brand'] === 'yes' && $brand_name): ?>
<div class="cannaiq-product-brand">
<?php echo esc_html($brand_name); ?>
</div>
<?php endif; ?>
<?php if ($settings['show_name'] === 'yes'): ?>
<h3 class="cannaiq-product-name">
<?php echo esc_html($product_name); ?>
</h3>
<?php endif; ?>
<div class="cannaiq-product-meta">
<?php if ($settings['show_category'] === 'yes' && $category): ?>
<span class="cannaiq-product-category"><?php echo esc_html($category); ?></span>
<?php endif; ?>
<?php if ($settings['show_strain_type'] === 'yes' && $strain_type): ?>
<span class="cannaiq-strain-badge" style="background-color: <?php echo esc_attr($strain_color); ?>;">
<?php echo esc_html(ucfirst($strain_type)); ?>
</span>
<?php endif; ?>
<?php if ($settings['show_weight'] === 'yes' && $weight): ?>
<span class="cannaiq-product-weight"><?php echo esc_html($weight); ?></span>
<?php endif; ?>
</div>
<?php if ($settings['show_thc'] === 'yes' || $settings['show_cbd'] === 'yes'): ?>
<div class="cannaiq-cannabinoids">
<?php if ($settings['show_thc'] === 'yes' && $thc !== null): ?>
<span class="cannaiq-thc">THC: <?php echo number_format((float)$thc, 1); ?>%</span>
<?php endif; ?>
<?php if ($settings['show_cbd'] === 'yes' && $cbd !== null): ?>
<span class="cannaiq-cbd">CBD: <?php echo number_format((float)$cbd, 1); ?>%</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($settings['show_effects'] === 'yes' && !empty($product['effects'])): ?>
<div class="cannaiq-effects">
<?php
$effects = $product['effects'];
if (is_array($effects)) {
arsort($effects);
$effects = array_slice($effects, 0, (int)$settings['effects_limit'], true);
foreach ($effects as $effect => $score):
?>
<span class="cannaiq-effect-badge"><?php echo esc_html(ucfirst($effect)); ?></span>
<?php
endforeach;
}
?>
</div>
<?php endif; ?>
<?php if ($settings['show_price'] === 'yes' && $price !== null): ?>
<div class="cannaiq-product-price">
<?php echo '$' . number_format((float)$price, 2); ?>
</div>
<?php endif; ?>
</div>
</div>
<?php
}
protected function fetch_products($settings) {
$dispensary_id = $settings['dispensary_id'];
if (empty($dispensary_id)) {
return [];
}
// Build API URL - use the payload query endpoint
$api_url = CANNAIQ_MENUS_API_URL . '/payloads/store/' . intval($dispensary_id) . '/query';
// Build query parameters matching the API expected format
$params = [];
if (!empty($settings['category'])) {
$params['category'] = $settings['category'];
}
if (!empty($settings['strain_type'])) {
$params['strain_type'] = $settings['strain_type'];
}
if (!empty($settings['brand'])) {
$params['brand'] = $settings['brand'];
}
if (!empty($settings['search'])) {
$params['search'] = $settings['search'];
}
if (!empty($settings['min_thc'])) {
$params['thc_min'] = $settings['min_thc'];
}
if (!empty($settings['max_price'])) {
$params['price_max'] = $settings['max_price'];
}
// Only show in-stock by default
$params['in_stock'] = 'true';
if (!empty($settings['limit'])) {
$params['limit'] = $settings['limit'];
}
// Handle sorting
$sort_map = [
'name' => ['sort' => 'name', 'order' => 'asc'],
'name_desc' => ['sort' => 'name', 'order' => 'desc'],
'price' => ['sort' => 'price', 'order' => 'asc'],
'price_desc' => ['sort' => 'price', 'order' => 'desc'],
'thc' => ['sort' => 'thc', 'order' => 'asc'],
'thc_desc' => ['sort' => 'thc', 'order' => 'desc'],
'brand' => ['sort' => 'brand', 'order' => 'asc'],
];
if (!empty($settings['sort_by']) && isset($sort_map[$settings['sort_by']])) {
$params['sort'] = $sort_map[$settings['sort_by']]['sort'];
$params['order'] = $sort_map[$settings['sort_by']]['order'];
}
$query_string = http_build_query($params);
$url = $api_url . ($query_string ? '?' . $query_string : '');
// Make request
$response = wp_remote_get($url, [
'timeout' => 30,
'headers' => [
'Accept' => 'application/json',
],
]);
if (is_wp_error($response)) {
return [];
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
return $data['products'] ?? [];
}
}