diff --git a/backend/public/downloads/cannaiq-menus-2.0.0.zip b/backend/public/downloads/cannaiq-menus-2.0.0.zip index 5d6ee49d..6402d45c 100644 Binary files a/backend/public/downloads/cannaiq-menus-2.0.0.zip and b/backend/public/downloads/cannaiq-menus-2.0.0.zip differ diff --git a/backend/src/utils/payload-storage.ts b/backend/src/utils/payload-storage.ts index 8abc1a3c..4cfbb013 100644 --- a/backend/src/utils/payload-storage.ts +++ b/backend/src/utils/payload-storage.ts @@ -417,23 +417,26 @@ export async function listPayloadMetadata( sizeBytes: number; sizeBytesRaw: number; fetchedAt: Date; + dispensary_name: string | null; + city: string | null; + state: string | null; }>> { const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; if (options.dispensaryId) { - conditions.push(`dispensary_id = $${paramIndex++}`); + conditions.push(`rcp.dispensary_id = $${paramIndex++}`); params.push(options.dispensaryId); } if (options.startDate) { - conditions.push(`fetched_at >= $${paramIndex++}`); + conditions.push(`rcp.fetched_at >= $${paramIndex++}`); params.push(options.startDate); } if (options.endDate) { - conditions.push(`fetched_at <= $${paramIndex++}`); + conditions.push(`rcp.fetched_at <= $${paramIndex++}`); params.push(options.endDate); } @@ -445,17 +448,21 @@ export async function listPayloadMetadata( const result = await pool.query(` SELECT - id, - dispensary_id, - crawl_run_id, - storage_path, - product_count, - size_bytes, - size_bytes_raw, - fetched_at - FROM raw_crawl_payloads + rcp.id, + rcp.dispensary_id, + rcp.crawl_run_id, + rcp.storage_path, + rcp.product_count, + rcp.size_bytes, + rcp.size_bytes_raw, + rcp.fetched_at, + d.name as dispensary_name, + d.city, + d.state + FROM raw_crawl_payloads rcp + LEFT JOIN dispensaries d ON d.id = rcp.dispensary_id ${whereClause} - ORDER BY fetched_at DESC + ORDER BY rcp.fetched_at DESC LIMIT $${paramIndex++} OFFSET $${paramIndex} `, params); @@ -467,7 +474,10 @@ export async function listPayloadMetadata( productCount: row.product_count, sizeBytes: row.size_bytes, sizeBytesRaw: row.size_bytes_raw, - fetchedAt: row.fetched_at + fetchedAt: row.fetched_at, + dispensary_name: row.dispensary_name, + city: row.city, + state: row.state })); } diff --git a/cannaiq/src/lib/api.ts b/cannaiq/src/lib/api.ts index a393460c..d1341643 100755 --- a/cannaiq/src/lib/api.ts +++ b/cannaiq/src/lib/api.ts @@ -3662,6 +3662,8 @@ export interface PayloadMetadata { sizeBytesRaw: number; fetchedAt: string; dispensary_name?: string; + city?: string; + state?: string; } // Type for high-frequency (per-store) schedules diff --git a/cannaiq/src/pages/PayloadsDashboard.tsx b/cannaiq/src/pages/PayloadsDashboard.tsx index 24e5b1b6..890dd954 100644 --- a/cannaiq/src/pages/PayloadsDashboard.tsx +++ b/cannaiq/src/pages/PayloadsDashboard.tsx @@ -347,10 +347,17 @@ export function PayloadsDashboard() {
- - - {payload.dispensary_name || `Store #${payload.dispensaryId}`} - + +
+
+ {payload.dispensary_name || `Store #${payload.dispensaryId}`} +
+ {(payload.city || payload.state) && ( +
+ {payload.city}{payload.city && payload.state ? ', ' : ''}{payload.state} +
+ )} +
diff --git a/wordpress-plugin/cannaiq-menus.php b/wordpress-plugin/cannaiq-menus.php index b01f46fc..5420cedd 100644 --- a/wordpress-plugin/cannaiq-menus.php +++ b/wordpress-plugin/cannaiq-menus.php @@ -69,9 +69,23 @@ class CannaIQ_Menus_Plugin { require_once CANNAIQ_MENUS_PLUGIN_DIR . 'widgets/dynamic-tags-extended.php'; } - // Register shortcodes - primary CannaIQ shortcodes + // Register shortcodes - primary CannaiQ shortcodes add_shortcode('cannaiq_products', [$this, 'products_shortcode']); add_shortcode('cannaiq_product', [$this, 'single_product_shortcode']); + add_shortcode('cannaiq_specials', [$this, 'specials_shortcode']); + add_shortcode('cannaiq_brands', [$this, 'brands_shortcode']); + add_shortcode('cannaiq_categories', [$this, 'categories_shortcode']); + + // Component shortcodes (v2.0) + add_shortcode('cannaiq_discount_badge', [$this, 'discount_badge_shortcode']); + add_shortcode('cannaiq_strain_badge', [$this, 'strain_badge_shortcode']); + add_shortcode('cannaiq_thc', [$this, 'thc_shortcode']); + add_shortcode('cannaiq_cbd', [$this, 'cbd_shortcode']); + add_shortcode('cannaiq_effects', [$this, 'effects_shortcode']); + add_shortcode('cannaiq_price', [$this, 'price_shortcode']); + add_shortcode('cannaiq_cart_button', [$this, 'cart_button_shortcode']); + add_shortcode('cannaiq_stock', [$this, 'stock_shortcode']); + add_shortcode('cannaiq_terpenes', [$this, 'terpenes_shortcode']); // DEPRECATED: Legacy shortcode alias for backward compatibility only add_shortcode('crawlsy_products', [$this, 'products_shortcode']); @@ -320,8 +334,9 @@ class CannaIQ_Menus_Plugin {

Usage

+

Shortcodes

- +
@@ -331,21 +346,175 @@ class CannaIQ_Menus_Plugin { - + - + + + + + + + + + + + + +
Shortcode
[cannaiq_products]Display a grid of products. Options: category_id, limit, columns, in_stockProduct grid. Options: category, brand, limit, columns, in_stock
[cannaiq_product id="123"]Display a single product by IDSingle product by ID
[cannaiq_specials]Products on sale. Options: limit, columns
[cannaiq_brands]Brand grid. Options: limit, columns
[cannaiq_categories]Category list. Options: style (list|grid)
-

Elementor Widgets

-

If you have Elementor installed, you can use the CannaiQ widgets:

+

Component Shortcodes (use inside product context)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ShortcodeDescription
[cannaiq_discount_badge]Discount ribbon/pill. Options: style (ribbon|pill|text)
[cannaiq_strain_badge]Sativa/Indica/Hybrid badge. Options: style (pill|text)
[cannaiq_thc]THC percentage. Options: style (meter|badge|pill|text)
[cannaiq_cbd]CBD percentage. Options: style (meter|badge|pill|text)
[cannaiq_effects]Effect chips with icons. Options: limit, icons (yes|no)
[cannaiq_price]Price display. Options: show_original (yes|no), show_weight (yes|no)
[cannaiq_cart_button]Add to cart button. Options: text, style (solid|outline)
[cannaiq_stock]Stock status. Options: style (badge|text|dot)
[cannaiq_terpenes]Terpene profile. Options: limit, style (chips|list|text)
+ +

Elementor Widgets

+

With Elementor installed, find CannaiQ widgets in the editor:

+ +

Layout Widgets

+ +

Component Widgets (v2.0)

+ + +

Card Templates (v2.0)

+ + +

Dynamic Tags

+

In Elementor, use dynamic tags to insert product data into any widget. Look for "CannaiQ Product" in the dynamic tags menu.

+ +
+ +

How to Build a Product Card

+

Use the modular components to build custom product cards. Here's an example layout:

+ +
+ +
+ +
+ 67% OFF +
+ SATIVA + 24.5% THC +
+
🌿
+
+ +
+

Hot Lava

+

by TruInfusion

+
+ 😴 Sleepy + 😌 Relaxed + 😊 Happy +
+
+ 1/8 oz + $45.00 + $15.00 +
+
ADD TO CART →
+
+
+ + +
+

Components Used:

+ + + + + + + + + + +
Discount RibbonTop-left corner badge
Product ImageWith badge overlays
Strain BadgeGreen Sativa pill
THC BadgeDark potency pill
Product NameDynamic tag
Brand NameDynamic tag
Effects DisplayColored chips with icons
Price BlockWeight + strikethrough + sale
Cart ButtonLinks to dispensary menu
+ +

Build Steps:

+
    +
  1. Add a Product Loop widget
  2. +
  3. Inside the loop, add a container
  4. +
  5. Add Product Image + Badges widget
  6. +
  7. Add heading with Product Name dynamic tag
  8. +
  9. Add text with Brand Name dynamic tag
  10. +
  11. Add Effects Display widget
  12. +
  13. Add Price Block widget
  14. +
  15. Add Cart Button widget
  16. +
+ +

+ Tip: Use the Premium Product Card template widget for a ready-to-use version of this layout! +

+
+
12, + 'columns' => 3 + ], $atts); + + $products = $this->fetch_specials($atts); + + if (!$products) { + return '

No specials found.

'; + } + + ob_start(); + include CANNAIQ_MENUS_PLUGIN_DIR . 'templates/product-grid.php'; + return ob_get_clean(); + } + + /** + * Brands Shortcode + */ + public function brands_shortcode($atts) { + $atts = shortcode_atts([ + 'limit' => 20, + 'columns' => 4 + ], $atts); + + $brands = $this->fetch_brands($atts); + + if (!$brands) { + return '

No brands found.

'; + } + + $columns = intval($atts['columns']); + ob_start(); + ?> +
+ +
+ + <?php echo esc_attr($brand['brand'] ?? $brand['name']); ?> + +

+ +

products

+ +
+ +
+ 'list' + ], $atts); + + $categories = $this->fetch_categories(); + + if (!$categories) { + return '

No categories found.

'; + } + + ob_start(); + if ($atts['style'] === 'grid') { + ?> +
+ +
+

+ +

products

+ +
+ +
+ + + 'ribbon'], $atts); + $product = $cannaiq_current_product; + + $original = $product['Prices'][0] ?? $product['regular_price'] ?? null; + $sale = $product['specialPrice'] ?? $product['sale_price'] ?? null; + + if (!$original || !$sale || $original <= $sale) return ''; + + $percent = round((($original - $sale) / $original) * 100); + $class = 'cannaiq-discount-ribbon cannaiq-discount-ribbon--' . esc_attr($atts['style']); + + return sprintf('%s%% OFF', $class, $percent); + } + + /** + * Strain Badge Shortcode + */ + public function strain_badge_shortcode($atts) { + global $cannaiq_current_product; + if (!$cannaiq_current_product) return ''; + + $atts = shortcode_atts(['style' => 'pill'], $atts); + $strain = strtolower($cannaiq_current_product['strainType'] ?? $cannaiq_current_product['strain_type'] ?? ''); + + if (empty($strain) || !in_array($strain, ['sativa', 'indica', 'hybrid'])) return ''; + + $colors = ['sativa' => '#22c55e', 'indica' => '#8b5cf6', 'hybrid' => '#f97316']; + $color = $colors[$strain]; + $style = $atts['style'] === 'pill' ? "background-color: {$color}; color: white;" : "color: {$color};"; + + return sprintf('%s', + esc_attr($atts['style']), esc_attr($style), esc_html(strtoupper($strain))); + } + + /** + * THC Shortcode + */ + public function thc_shortcode($atts) { + global $cannaiq_current_product; + if (!$cannaiq_current_product) return ''; + + $atts = shortcode_atts(['style' => 'badge'], $atts); + $thc = $cannaiq_current_product['THCContent']['range'][0] ?? $cannaiq_current_product['THC'] ?? $cannaiq_current_product['thc_percentage'] ?? null; + + if (!$thc || $thc <= 0) return ''; + + $formatted = number_format((float)$thc, 1) . '% THC'; + return sprintf('%s', + esc_attr($atts['style']), esc_html($formatted)); + } + + /** + * CBD Shortcode + */ + public function cbd_shortcode($atts) { + global $cannaiq_current_product; + if (!$cannaiq_current_product) return ''; + + $atts = shortcode_atts(['style' => 'badge'], $atts); + $cbd = $cannaiq_current_product['CBDContent']['range'][0] ?? $cannaiq_current_product['CBD'] ?? $cannaiq_current_product['cbd_percentage'] ?? null; + + if (!$cbd || $cbd <= 0) return ''; + + $formatted = number_format((float)$cbd, 1) . '% CBD'; + return sprintf('%s', + esc_attr($atts['style']), esc_html($formatted)); + } + + /** + * Effects Shortcode + */ + public function effects_shortcode($atts) { + global $cannaiq_current_product; + if (!$cannaiq_current_product) return ''; + + $atts = shortcode_atts(['limit' => 3, 'icons' => 'yes'], $atts); + $effects = $cannaiq_current_product['effects'] ?? []; + + if (empty($effects) || !is_array($effects)) return ''; + + if (!isset($effects[0])) { + arsort($effects); + $effects = array_keys($effects); + } + + $effects = array_slice($effects, 0, intval($atts['limit'])); + return cannaiq_render_effects($effects, [ + 'limit' => intval($atts['limit']), + 'show_icon' => $atts['icons'] === 'yes', + 'size' => 'medium' + ]); + } + + /** + * Price Shortcode + */ + public function price_shortcode($atts) { + global $cannaiq_current_product; + if (!$cannaiq_current_product) return ''; + + $atts = shortcode_atts(['show_original' => 'yes', 'show_weight' => 'yes'], $atts); + $product = $cannaiq_current_product; + + $original = $product['Prices'][0] ?? $product['regular_price'] ?? null; + $sale = $product['specialPrice'] ?? $product['sale_price'] ?? null; + $weight = $product['Options'][0] ?? $product['weight'] ?? ''; + + if (!$original || $original <= 0) return ''; + + $is_sale = $sale && $sale > 0 && $sale < $original; + ob_start(); + ?> + + + + + + + $ + + $ + + $ + + + 'ADD TO CART', 'style' => 'solid'], $atts); + $url = $cannaiq_current_product['menuUrl'] ?? $cannaiq_current_product['menu_url'] ?? '#'; + + return sprintf('%s', + esc_url($url), esc_attr($atts['style']), esc_html($atts['text'])); + } + + /** + * Stock Shortcode + */ + public function stock_shortcode($atts) { + global $cannaiq_current_product; + if (!$cannaiq_current_product) return ''; + + $atts = shortcode_atts(['style' => 'badge'], $atts); + $status = $cannaiq_current_product['Status'] ?? ''; + $in_stock = ($status === 'Active' || $status === 'In Stock' || !empty($cannaiq_current_product['in_stock'])); + + $text = $in_stock ? 'In Stock' : 'Out of Stock'; + $class = 'cannaiq-stock-indicator cannaiq-stock-indicator--' . ($in_stock ? 'in-stock' : 'out-of-stock'); + if ($atts['style'] === 'badge') $class .= ' cannaiq-stock-indicator--badge'; + + $dot = $atts['style'] === 'dot' ? '' : ''; + return sprintf('%s%s', esc_attr($class), $dot, esc_html($text)); + } + + /** + * Terpenes Shortcode + */ + public function terpenes_shortcode($atts) { + global $cannaiq_current_product; + if (!$cannaiq_current_product) return ''; + + $atts = shortcode_atts(['limit' => 3, 'style' => 'chips'], $atts); + $terpenes = $cannaiq_current_product['terpenes'] ?? []; + + if (empty($terpenes) || !is_array($terpenes)) return ''; + + $terpenes = array_slice($terpenes, 0, intval($atts['limit'])); + ob_start(); + + if ($atts['style'] === 'chips') { + echo '
'; + foreach ($terpenes as $terp) { + $name = $terp['name'] ?? ''; + $percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : ''; + printf('%s%s', + esc_html($name), esc_html($percent)); + } + echo '
'; + } elseif ($atts['style'] === 'text') { + $parts = []; + foreach ($terpenes as $terp) { + $name = $terp['name'] ?? ''; + $percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : ''; + $parts[] = $name . ($percent ? ' ' . $percent : ''); + } + echo esc_html(implode(', ', $parts)); + } else { + echo '
'; + foreach ($terpenes as $terp) { + $name = $terp['name'] ?? ''; + $percent = isset($terp['percent']) ? number_format((float)$terp['percent'], 2) . '%' : ''; + printf('
%s%s
', + esc_html($name), esc_html($percent)); + } + echo '
'; + } + + return ob_get_clean(); + } + /** * Fetch Products from API */