feat: Add premade card templates and click analytics
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

WordPress Plugin v2.0.0:
- Add Promo Banner widget (dark banner with deal text)
- Add Horizontal Product Row widget (wide list format)
- Add Category Card widget (image-based categories)
- Add Compact Card widget (dense grid layout)
- Add CannaiQAnalytics click tracking (tracks add_to_cart,
  product_view, promo_click, category_click events)
- Register cannaiq-templates Elementor category
- Fix branding: CannaiQAnalytics (not CannaIQAnalytics)

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Kelly
2025-12-17 02:03:28 -07:00
parent 87da7625cd
commit 0b4ed48d2f
8 changed files with 1510 additions and 4 deletions

View File

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

View File

@@ -1,15 +1,118 @@
/**
* CannaIQ Menus - WordPress Plugin JavaScript
* v1.5.3
* v2.0.0
*/
(function($) {
'use strict';
/**
* Click Analytics Tracker
*/
var CannaiQAnalytics = {
/**
* Track a click event
* @param {string} eventType - Type of event (add_to_cart, product_view, promo_click, etc)
* @param {object} data - Event data (product_id, store_id, product_name, etc)
*/
track: function(eventType, data) {
if (!window.cannaiqAnalytics || !window.cannaiqAnalytics.enabled) {
return;
}
var payload = {
event_type: eventType,
store_id: data.store_id || window.cannaiqAnalytics.store_id,
product_id: data.product_id || null,
product_name: data.product_name || null,
product_price: data.product_price || null,
category: data.category || null,
url: data.url || window.location.href,
referrer: document.referrer || null,
timestamp: new Date().toISOString()
};
// Send to analytics endpoint
$.ajax({
url: window.cannaiqAnalytics.api_url + '/analytics/click',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
headers: {
'Authorization': 'Bearer ' + window.cannaiqAnalytics.api_token
},
// Fire and forget - don't block user interaction
async: true
});
},
/**
* Initialize click tracking on all CannaiQ elements
*/
init: function() {
var self = this;
// Track Add to Cart clicks
$(document).on('click', '.cannaiq-cart-button, .cannaiq-add-to-cart, .cannaiq-hr-add-btn, .cannaiq-cc-button, [class*="cannaiq"][href*="dutchie"], [class*="cannaiq"][href*="iheartjane"]', function(e) {
var $el = $(this);
var $card = $el.closest('[data-product-id], .cannaiq-product-card, .cannaiq-premium-card, .cannaiq-horizontal-row, .cannaiq-compact-card');
self.track('add_to_cart', {
product_id: $card.data('product-id') || $el.data('product-id'),
product_name: $card.data('product-name') || $el.data('product-name') || $card.find('.cannaiq-product-name, .cannaiq-premium-name, .cannaiq-hr-name, .cannaiq-cc-name').first().text().trim(),
product_price: $card.data('product-price') || $el.data('product-price'),
store_id: $card.data('store-id') || $el.data('store-id'),
category: $card.data('category') || $el.data('category'),
url: $el.attr('href')
});
});
// Track product card clicks (view intent)
$(document).on('click', '.cannaiq-product-card, .cannaiq-premium-card, .cannaiq-special-card', function(e) {
// Don't double-track if clicking the cart button
if ($(e.target).closest('.cannaiq-cart-button, .cannaiq-add-to-cart').length) {
return;
}
var $card = $(this);
self.track('product_view', {
product_id: $card.data('product-id'),
product_name: $card.data('product-name') || $card.find('.cannaiq-product-name, .cannaiq-premium-name').first().text().trim(),
product_price: $card.data('product-price'),
store_id: $card.data('store-id'),
category: $card.data('category')
});
});
// Track promo banner clicks
$(document).on('click', '.cannaiq-promo-banner .cannaiq-promo-button, .cannaiq-promo-banner', function(e) {
var $banner = $(this).closest('.cannaiq-promo-banner');
self.track('promo_click', {
store_id: $banner.data('store-id'),
promo_headline: $banner.find('.cannaiq-promo-headline').text().trim(),
url: $(this).attr('href') || $banner.find('a').first().attr('href')
});
});
// Track category clicks
$(document).on('click', '.cannaiq-category-card, .cannaiq-category-item', function(e) {
var $cat = $(this);
self.track('category_click', {
store_id: $cat.data('store-id'),
category: $cat.data('category') || $cat.find('.cannaiq-cat-name, .cannaiq-category-name').first().text().trim(),
url: $cat.attr('href')
});
});
}
};
/**
* Initialize plugin
*/
$(document).ready(function() {
// Initialize analytics tracking
CannaiQAnalytics.init();
// Lazy load images
if ('IntersectionObserver' in window) {
const imageObserver = new IntersectionObserver((entries, observer) => {
@@ -49,10 +152,13 @@
threshold: 0.1
});
document.querySelectorAll('.cannaiq-product-card').forEach(card => {
document.querySelectorAll('.cannaiq-product-card, .cannaiq-premium-card, .cannaiq-compact-card').forEach(card => {
cardObserver.observe(card);
});
}
});
// Expose for external use
window.CannaiQAnalytics = CannaiQAnalytics;
})(jQuery);

View File

@@ -44,7 +44,7 @@ class CannaIQ_Menus_Plugin {
}
/**
* Register CannaIQ Elementor Widget Category
* Register CannaIQ Elementor Widget Categories
*/
public function register_elementor_category($elements_manager) {
$elements_manager->add_category(
@@ -54,6 +54,13 @@ class CannaIQ_Menus_Plugin {
'icon' => 'fa fa-cannabis',
]
);
$elements_manager->add_category(
'cannaiq-templates',
[
'title' => __('CannaiQ Templates', 'cannaiq-menus'),
'icon' => 'fa fa-th-large',
]
);
}
public function init() {
@@ -116,6 +123,10 @@ class CannaIQ_Menus_Plugin {
// Card templates (v2.0)
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-premium.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-promo-banner.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-horizontal.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-category.php';
require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/card-template-compact.php';
// Register legacy widgets
$widgets_manager->register(new \CannaIQ_Menus_Product_Grid_Widget());
@@ -137,6 +148,10 @@ class CannaIQ_Menus_Plugin {
// Register card templates (v2.0)
$widgets_manager->register(new \CannaIQ_Premium_Card_Widget());
$widgets_manager->register(new \CannaIQ_Promo_Banner_Widget());
$widgets_manager->register(new \CannaIQ_Card_Horizontal_Widget());
$widgets_manager->register(new \CannaIQ_Card_Category_Widget());
$widgets_manager->register(new \CannaIQ_Card_Compact_Widget());
}
/**
@@ -166,6 +181,15 @@ class CannaIQ_Menus_Plugin {
CANNAIQ_MENUS_VERSION,
true
);
// Pass analytics config to JavaScript
$api_token = get_option('cannaiq_api_token');
wp_localize_script('cannaiq-menus-script', 'cannaiqAnalytics', [
'enabled' => !empty($api_token),
'api_url' => CANNAIQ_MENUS_API_URL,
'api_token' => $api_token,
'store_id' => get_option('cannaiq_default_store_id', 1),
]);
}
/**

View File

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

View File

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

View File

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

View File

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