Files
cannaiq/docs/WORDPRESS_PLUGIN_SPEC.md
Kelly 9d8972aa86 Fix category-crawler-jobs store lookup query
- Fix column name from s.dutchie_plus_url to s.dutchie_url
- Add availability tracking and product freshness APIs
- Add crawl script for sequential dispensary processing

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 00:07:00 -07:00

92 KiB

Cannabrands WordPress Menu Plugin - Technical Specification

Overview

This document specifies the WordPress plugin that allows dispensaries to display their product menus, specials, and carousels on their WordPress websites - replacing the need for Dutchie Plus.

Mental Model

┌─────────────────────────────────────────────────────────────────────┐
│                      CRAWLER + CANONICAL TABLES                      │
│                       (Single Source of Truth)                       │
└─────────────────────────────────┬───────────────────────────────────┘
                                  │
              ┌───────────────────┴───────────────────┐
              │                                       │
              ▼                                       ▼
┌─────────────────────────────┐       ┌─────────────────────────────┐
│    BRAND INTELLIGENCE       │       │   STORE MENU PLUGIN         │
│    (Cannabrands App)        │       │   (WordPress/Dispensaries)  │
├─────────────────────────────┤       ├─────────────────────────────┤
│ • brand_daily_metrics       │       │ • products (current state)  │
│ • brand_promo_daily_metrics │       │ • categories                │
│ • brand_store_events        │       │ • specials (parsed deals)   │
│ • v_brand_store_detail      │       │                             │
├─────────────────────────────┤       ├─────────────────────────────┤
│ Auth: JWT with brand_key    │       │ Auth: API key + store_id    │
│ Path: /v1/brands/:key/...   │       │ Path: /v1/stores/:key/...   │
└─────────────────────────────┘       └─────────────────────────────┘

Non-Negotiable Constraints

  1. DO NOT modify the crawler
  2. DO NOT break existing API endpoints
  3. DO NOT couple plugin to intelligence/analytics tables
  4. Store menu endpoints use current state only (not historical metrics)
  5. Plugin is read-only - no writes to our database

1. Store-Facing API Endpoints

These endpoints are specifically for the WordPress plugin. They are separate from Brand Intelligence endpoints.

1.1 Authentication

Header: X-Store-API-Key: {api_key}

API keys are issued per-store and stored in a store_api_keys table:

CREATE TABLE store_api_keys (
  id            SERIAL PRIMARY KEY,
  store_id      INTEGER NOT NULL REFERENCES stores(id),
  api_key       VARCHAR(64) NOT NULL,
  name          VARCHAR(100),  -- "WordPress Plugin", "Mobile App", etc.
  is_active     BOOLEAN NOT NULL DEFAULT TRUE,
  rate_limit    INTEGER DEFAULT 1000,  -- requests per hour
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  last_used_at  TIMESTAMPTZ,

  CONSTRAINT uq_store_api_keys_key UNIQUE (api_key)
);

CREATE INDEX idx_store_api_keys_key ON store_api_keys(api_key) WHERE is_active = TRUE;
CREATE INDEX idx_store_api_keys_store ON store_api_keys(store_id);

1.2 GET /v1/stores/:store_key/menu

Returns the full product menu for a store, optionally grouped by category.

Path Parameters:

Param Type Description
store_key string Store slug or ID

Query Parameters:

Param Type Required Default Description
category string No - Filter by category slug
brand string No - Filter by brand name
in_stock boolean No true Filter by stock status
sort string No name Sort: name, price_asc, price_desc, thc_desc, newest
limit number No 500 Max products (max 1000)
offset number No 0 Pagination offset
group_by_category boolean No false Group products by category

Response (Flat List - group_by_category=false):

{
  "store": {
    "id": 1,
    "name": "Deeply Rooted",
    "slug": "deeply-rooted-sacramento",
    "city": "Sacramento",
    "state": "CA",
    "logo_url": "https://...",
    "dutchie_url": "https://dutchie.com/dispensary/deeply-rooted"
  },
  "meta": {
    "total": 342,
    "limit": 50,
    "offset": 0,
    "has_more": true,
    "generated_at": "2025-01-15T08:00:00.000Z",
    "cache_ttl_seconds": 300
  },
  "products": [
    {
      "id": 12345,
      "slug": "raw-garden-live-resin-slurm-og-1g",
      "name": "Live Resin - Slurm OG",
      "full_name": "Raw Garden Live Resin - Slurm OG",
      "brand": "Raw Garden",
      "category": {
        "id": 5,
        "name": "Concentrates",
        "slug": "concentrates",
        "parent_slug": null
      },
      "subcategory": {
        "id": 12,
        "name": "Live Resin",
        "slug": "live-resin",
        "parent_slug": "concentrates"
      },
      "strain_type": "hybrid",
      "weight": "1g",
      "thc_percent": 82.5,
      "cbd_percent": 0.1,
      "price": {
        "current": 45.00,
        "regular": 55.00,
        "is_on_special": true,
        "discount_percent": 18.2,
        "formatted": {
          "current": "$45.00",
          "regular": "$55.00",
          "savings": "$10.00"
        }
      },
      "special": {
        "active": true,
        "text": "20% off all Raw Garden",
        "type": "percent_off",
        "ends_at": null
      },
      "stock": {
        "in_stock": true,
        "quantity": null
      },
      "images": {
        "thumbnail": "https://images.dutchie.com/.../thumb.jpg",
        "medium": "https://images.dutchie.com/.../medium.jpg",
        "full": "https://images.dutchie.com/.../full.jpg"
      },
      "description": "Premium live resin with...",
      "terpenes": ["Limonene", "Myrcene", "Caryophyllene"],
      "effects": ["Relaxed", "Happy", "Euphoric"],
      "dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/raw-garden-live-resin-slurm-og",
      "last_updated": "2025-01-15T06:30:00.000Z"
    }
  ]
}

Response (Grouped - group_by_category=true):

{
  "store": { ... },
  "meta": {
    "total": 342,
    "categories_count": 8,
    "generated_at": "2025-01-15T08:00:00.000Z",
    "cache_ttl_seconds": 300
  },
  "categories": [
    {
      "id": 1,
      "name": "Flower",
      "slug": "flower",
      "product_count": 85,
      "products": [
        { ... },
        { ... }
      ]
    },
    {
      "id": 2,
      "name": "Pre-Rolls",
      "slug": "pre-rolls",
      "product_count": 42,
      "products": [ ... ]
    }
  ]
}

1.3 GET /v1/stores/:store_key/specials

Returns products currently on special/deal.

Query Parameters:

Param Type Required Default Description
category string No - Filter by category slug
brand string No - Filter by brand name
special_type string No - Filter: percent_off, dollar_off, bogo, bundle
sort string No discount_desc Sort: discount_desc, price_asc, name
limit number No 50 Max products

Response:

{
  "store": { ... },
  "meta": {
    "total_specials": 34,
    "generated_at": "2025-01-15T08:00:00.000Z",
    "cache_ttl_seconds": 300
  },
  "specials": [
    {
      "id": 12345,
      "slug": "raw-garden-live-resin-slurm-og-1g",
      "name": "Live Resin - Slurm OG",
      "brand": "Raw Garden",
      "category": {
        "name": "Concentrates",
        "slug": "concentrates"
      },
      "strain_type": "hybrid",
      "weight": "1g",
      "thc_percent": 82.5,
      "price": {
        "current": 45.00,
        "regular": 55.00,
        "discount_percent": 18.2,
        "formatted": {
          "current": "$45.00",
          "regular": "$55.00",
          "savings": "$10.00"
        }
      },
      "special": {
        "text": "20% off all Raw Garden",
        "type": "percent_off",
        "value": 20,
        "badge_text": "20% OFF",
        "ends_at": null
      },
      "images": {
        "thumbnail": "https://...",
        "medium": "https://..."
      },
      "in_stock": true,
      "dutchie_url": "https://..."
    }
  ],
  "summary": {
    "by_type": {
      "percent_off": 18,
      "dollar_off": 5,
      "bogo": 8,
      "bundle": 2,
      "other": 1
    },
    "by_category": [
      { "name": "Flower", "count": 12 },
      { "name": "Concentrates", "count": 10 },
      { "name": "Edibles", "count": 8 }
    ],
    "avg_discount_percent": 22.5
  }
}

1.4 GET /v1/stores/:store_key/categories

Returns the category tree for a store's menu.

Response:

{
  "store": {
    "id": 1,
    "name": "Deeply Rooted",
    "slug": "deeply-rooted-sacramento"
  },
  "categories": [
    {
      "id": 1,
      "name": "Flower",
      "slug": "flower",
      "product_count": 85,
      "in_stock_count": 72,
      "icon": "flower",
      "children": [
        {
          "id": 10,
          "name": "Indoor",
          "slug": "indoor",
          "product_count": 45,
          "in_stock_count": 40
        },
        {
          "id": 11,
          "name": "Outdoor",
          "slug": "outdoor",
          "product_count": 25,
          "in_stock_count": 20
        }
      ]
    },
    {
      "id": 2,
      "name": "Pre-Rolls",
      "slug": "pre-rolls",
      "product_count": 42,
      "in_stock_count": 38,
      "icon": "joint",
      "children": []
    },
    {
      "id": 3,
      "name": "Vapes",
      "slug": "vapes",
      "product_count": 65,
      "in_stock_count": 58,
      "icon": "vape",
      "children": [
        {
          "id": 20,
          "name": "Cartridges",
          "slug": "cartridges",
          "product_count": 40
        },
        {
          "id": 21,
          "name": "Disposables",
          "slug": "disposables",
          "product_count": 25
        }
      ]
    }
  ]
}

1.5 GET /v1/stores/:store_key/brands

Returns all brands available at the store.

Response:

{
  "store": { ... },
  "brands": [
    {
      "name": "Raw Garden",
      "slug": "raw-garden",
      "product_count": 24,
      "in_stock_count": 21,
      "on_special_count": 3,
      "logo_url": null,
      "categories": ["Concentrates", "Vapes"]
    },
    {
      "name": "Stiiizy",
      "slug": "stiiizy",
      "product_count": 18,
      "in_stock_count": 15,
      "on_special_count": 0,
      "logo_url": null,
      "categories": ["Vapes", "Flower"]
    }
  ]
}

1.6 GET /v1/stores/:store_key/product/:product_slug

Returns a single product's full details.

Response:

{
  "product": {
    "id": 12345,
    "slug": "raw-garden-live-resin-slurm-og-1g",
    "name": "Live Resin - Slurm OG",
    "full_name": "Raw Garden Live Resin - Slurm OG",
    "brand": "Raw Garden",
    "category": {
      "id": 5,
      "name": "Concentrates",
      "slug": "concentrates"
    },
    "subcategory": {
      "id": 12,
      "name": "Live Resin",
      "slug": "live-resin"
    },
    "strain_type": "hybrid",
    "weight": "1g",
    "thc_percent": 82.5,
    "cbd_percent": 0.1,
    "price": {
      "current": 45.00,
      "regular": 55.00,
      "is_on_special": true,
      "discount_percent": 18.2
    },
    "special": {
      "active": true,
      "text": "20% off all Raw Garden",
      "type": "percent_off"
    },
    "in_stock": true,
    "images": {
      "thumbnail": "https://...",
      "medium": "https://...",
      "full": "https://..."
    },
    "description": "Premium live resin extracted from fresh frozen cannabis...",
    "terpenes": ["Limonene", "Myrcene", "Caryophyllene"],
    "effects": ["Relaxed", "Happy", "Euphoric"],
    "flavors": ["Citrus", "Earthy", "Pine"],
    "lineage": "Unknown lineage",
    "dutchie_url": "https://...",
    "last_updated": "2025-01-15T06:30:00.000Z"
  },
  "related_products": [
    { ... },
    { ... }
  ]
}

2. WordPress Plugin Architecture

2.1 Directory Structure

cannabrands-menu/
├── cannabrands-menu.php              # Main plugin file
├── readme.txt                        # WordPress.org readme
├── uninstall.php                     # Cleanup on uninstall
│
├── includes/
│   ├── class-cannabrands-plugin.php           # Main plugin class
│   ├── class-cannabrands-api-client.php       # API communication
│   ├── class-cannabrands-cache.php            # Transient caching
│   ├── class-cannabrands-settings.php         # Admin settings page
│   ├── class-cannabrands-shortcodes.php       # Shortcode handlers
│   ├── class-cannabrands-rest-proxy.php       # Optional: local REST proxy
│   │
│   ├── elementor/
│   │   ├── class-cannabrands-elementor.php           # Elementor integration
│   │   ├── widgets/
│   │   │   ├── class-widget-menu.php                 # Menu widget
│   │   │   ├── class-widget-specials.php             # Specials widget
│   │   │   ├── class-widget-carousel.php             # Product carousel
│   │   │   ├── class-widget-categories.php           # Category list/grid
│   │   │   └── class-widget-product-card.php         # Single product card
│   │   └── controls/
│   │       └── class-category-control.php            # Custom category selector
│   │
│   └── blocks/
│       ├── class-cannabrands-blocks.php       # Gutenberg blocks registration
│       ├── menu/
│       │   ├── block.json
│       │   ├── edit.js
│       │   └── render.php
│       ├── specials/
│       │   ├── block.json
│       │   ├── edit.js
│       │   └── render.php
│       └── carousel/
│           ├── block.json
│           ├── edit.js
│           └── render.php
│
├── assets/
│   ├── css/
│   │   ├── cannabrands-menu.css              # Base styles
│   │   ├── cannabrands-menu-elementor.css    # Elementor-specific
│   │   └── cannabrands-menu-admin.css        # Admin styles
│   │
│   ├── js/
│   │   ├── cannabrands-menu.js               # Frontend JS (carousel, etc.)
│   │   ├── cannabrands-admin.js              # Admin JS
│   │   └── blocks/                           # Gutenberg block JS
│   │
│   └── images/
│       └── placeholder.png                   # Product placeholder
│
├── templates/
│   ├── menu/
│   │   ├── menu-grid.php                     # Grid layout template
│   │   ├── menu-list.php                     # List layout template
│   │   └── menu-grouped.php                  # Category-grouped template
│   │
│   ├── specials/
│   │   ├── specials-grid.php
│   │   └── specials-banner.php
│   │
│   ├── carousel/
│   │   └── carousel.php
│   │
│   ├── partials/
│   │   ├── product-card.php                  # Reusable product card
│   │   ├── product-card-compact.php          # Compact variant
│   │   ├── price-display.php                 # Price with special handling
│   │   ├── category-pill.php                 # Category badge
│   │   └── special-badge.php                 # Deal badge
│   │
│   └── admin/
│       └── settings-page.php
│
└── languages/
    └── cannabrands-menu.pot                  # Translation template

2.2 Main Plugin Class

<?php
/**
 * Plugin Name: Cannabrands Menu
 * Description: Display your dispensary menu, specials, and product carousels
 * Version: 1.0.0
 * Author: Cannabrands
 * Text Domain: cannabrands-menu
 */

if (!defined('ABSPATH')) exit;

define('CANNABRANDS_MENU_VERSION', '1.0.0');
define('CANNABRANDS_MENU_PATH', plugin_dir_path(__FILE__));
define('CANNABRANDS_MENU_URL', plugin_dir_url(__FILE__));

class Cannabrands_Menu_Plugin {

    private static $instance = null;

    /** @var Cannabrands_Api_Client */
    public $api;

    /** @var Cannabrands_Cache */
    public $cache;

    /** @var Cannabrands_Settings */
    public $settings;

    public static function instance() {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        $this->load_dependencies();
        $this->init_components();
        $this->register_hooks();
    }

    private function load_dependencies() {
        require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-api-client.php';
        require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-cache.php';
        require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-settings.php';
        require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-shortcodes.php';

        // Elementor (if active)
        if (did_action('elementor/loaded')) {
            require_once CANNABRANDS_MENU_PATH . 'includes/elementor/class-cannabrands-elementor.php';
        }

        // Gutenberg blocks
        require_once CANNABRANDS_MENU_PATH . 'includes/blocks/class-cannabrands-blocks.php';
    }

    private function init_components() {
        $this->settings = new Cannabrands_Settings();
        $this->cache = new Cannabrands_Cache();
        $this->api = new Cannabrands_Api_Client($this->settings, $this->cache);

        new Cannabrands_Shortcodes($this->api);

        if (did_action('elementor/loaded')) {
            new Cannabrands_Elementor($this->api);
        }

        new Cannabrands_Blocks($this->api);
    }

    private function register_hooks() {
        add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);

        // AJAX handlers for live preview in admin
        add_action('wp_ajax_cannabrands_preview_menu', [$this, 'ajax_preview_menu']);
        add_action('wp_ajax_cannabrands_clear_cache', [$this, 'ajax_clear_cache']);
    }

    public function enqueue_frontend_assets() {
        wp_enqueue_style(
            'cannabrands-menu',
            CANNABRANDS_MENU_URL . 'assets/css/cannabrands-menu.css',
            [],
            CANNABRANDS_MENU_VERSION
        );

        wp_enqueue_script(
            'cannabrands-menu',
            CANNABRANDS_MENU_URL . 'assets/js/cannabrands-menu.js',
            ['jquery'],
            CANNABRANDS_MENU_VERSION,
            true
        );

        // Pass config to JS
        wp_localize_script('cannabrands-menu', 'cannabrandsMenu', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce('cannabrands_menu'),
            'storeSlug' => $this->settings->get('store_slug'),
        ]);
    }

    public function enqueue_admin_assets($hook) {
        if ($hook !== 'settings_page_cannabrands-menu') {
            return;
        }

        wp_enqueue_style(
            'cannabrands-menu-admin',
            CANNABRANDS_MENU_URL . 'assets/css/cannabrands-menu-admin.css',
            [],
            CANNABRANDS_MENU_VERSION
        );

        wp_enqueue_script(
            'cannabrands-menu-admin',
            CANNABRANDS_MENU_URL . 'assets/js/cannabrands-admin.js',
            ['jquery'],
            CANNABRANDS_MENU_VERSION,
            true
        );
    }

    public function ajax_clear_cache() {
        check_ajax_referer('cannabrands_admin', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error('Unauthorized');
        }

        $this->cache->clear_all();
        wp_send_json_success(['message' => 'Cache cleared successfully']);
    }
}

// Initialize
function cannabrands_menu() {
    return Cannabrands_Menu_Plugin::instance();
}

add_action('plugins_loaded', 'cannabrands_menu');

2.3 API Client Class

<?php

class Cannabrands_Api_Client {

    /** @var Cannabrands_Settings */
    private $settings;

    /** @var Cannabrands_Cache */
    private $cache;

    public function __construct(Cannabrands_Settings $settings, Cannabrands_Cache $cache) {
        $this->settings = $settings;
        $this->cache = $cache;
    }

    /**
     * Get the full menu for the configured store
     *
     * @param array $params {
     *     @type string $category      Category slug filter
     *     @type string $brand         Brand name filter
     *     @type bool   $in_stock      Filter by stock status (default: true)
     *     @type string $sort          Sort order: name, price_asc, price_desc, thc_desc, newest
     *     @type int    $limit         Max products (default: 500)
     *     @type int    $offset        Pagination offset
     *     @type bool   $group_by_category  Group products by category
     * }
     * @return array|WP_Error
     */
    public function get_menu(array $params = []) {
        $defaults = [
            'in_stock' => true,
            'sort' => 'name',
            'limit' => 500,
            'offset' => 0,
            'group_by_category' => false,
        ];

        $params = wp_parse_args($params, $defaults);

        $cache_key = $this->build_cache_key('menu', $params);
        $cached = $this->cache->get($cache_key);

        if ($cached !== false) {
            return $cached;
        }

        $response = $this->request('GET', '/menu', $params);

        if (!is_wp_error($response)) {
            $this->cache->set($cache_key, $response, $this->get_cache_ttl());
        }

        return $response;
    }

    /**
     * Get products currently on special
     *
     * @param array $params {
     *     @type string $category      Category slug filter
     *     @type string $brand         Brand name filter
     *     @type string $special_type  Filter: percent_off, dollar_off, bogo, bundle
     *     @type string $sort          Sort: discount_desc, price_asc, name
     *     @type int    $limit         Max products (default: 50)
     * }
     * @return array|WP_Error
     */
    public function get_specials(array $params = []) {
        $defaults = [
            'sort' => 'discount_desc',
            'limit' => 50,
        ];

        $params = wp_parse_args($params, $defaults);

        $cache_key = $this->build_cache_key('specials', $params);
        $cached = $this->cache->get($cache_key);

        if ($cached !== false) {
            return $cached;
        }

        $response = $this->request('GET', '/specials', $params);

        if (!is_wp_error($response)) {
            $this->cache->set($cache_key, $response, $this->get_cache_ttl());
        }

        return $response;
    }

    /**
     * Get category tree
     *
     * @return array|WP_Error
     */
    public function get_categories() {
        $cache_key = $this->build_cache_key('categories', []);
        $cached = $this->cache->get($cache_key);

        if ($cached !== false) {
            return $cached;
        }

        $response = $this->request('GET', '/categories');

        if (!is_wp_error($response)) {
            // Categories change less frequently - cache longer
            $this->cache->set($cache_key, $response, $this->get_cache_ttl() * 2);
        }

        return $response;
    }

    /**
     * Get brands list
     *
     * @return array|WP_Error
     */
    public function get_brands() {
        $cache_key = $this->build_cache_key('brands', []);
        $cached = $this->cache->get($cache_key);

        if ($cached !== false) {
            return $cached;
        }

        $response = $this->request('GET', '/brands');

        if (!is_wp_error($response)) {
            $this->cache->set($cache_key, $response, $this->get_cache_ttl() * 2);
        }

        return $response;
    }

    /**
     * Get single product details
     *
     * @param string $product_slug
     * @return array|WP_Error
     */
    public function get_product(string $product_slug) {
        $cache_key = $this->build_cache_key('product', ['slug' => $product_slug]);
        $cached = $this->cache->get($cache_key);

        if ($cached !== false) {
            return $cached;
        }

        $response = $this->request('GET', '/product/' . sanitize_title($product_slug));

        if (!is_wp_error($response)) {
            $this->cache->set($cache_key, $response, $this->get_cache_ttl());
        }

        return $response;
    }

    /**
     * Make API request
     */
    private function request(string $method, string $endpoint, array $params = []) {
        $base_url = $this->settings->get('api_base_url');
        $store_key = $this->settings->get('store_key');
        $api_key = $this->settings->get('api_key');

        if (empty($base_url) || empty($store_key) || empty($api_key)) {
            return new WP_Error(
                'cannabrands_not_configured',
                __('Cannabrands Menu plugin is not configured. Please enter your API credentials in Settings.', 'cannabrands-menu')
            );
        }

        $url = trailingslashit($base_url) . 'v1/stores/' . $store_key . $endpoint;

        if (!empty($params) && $method === 'GET') {
            $url = add_query_arg($params, $url);
        }

        $args = [
            'method' => $method,
            'timeout' => 30,
            'headers' => [
                'X-Store-API-Key' => $api_key,
                'Accept' => 'application/json',
                'User-Agent' => 'Cannabrands-Menu-Plugin/' . CANNABRANDS_MENU_VERSION,
            ],
        ];

        $response = wp_remote_request($url, $args);

        if (is_wp_error($response)) {
            return $response;
        }

        $code = wp_remote_retrieve_response_code($response);
        $body = wp_remote_retrieve_body($response);
        $data = json_decode($body, true);

        if ($code !== 200) {
            $message = isset($data['message']) ? $data['message'] : 'API request failed';
            return new WP_Error('cannabrands_api_error', $message, ['status' => $code]);
        }

        return $data;
    }

    private function build_cache_key(string $endpoint, array $params): string {
        $store_key = $this->settings->get('store_key');
        $param_hash = md5(serialize($params));
        return "cannabrands_{$store_key}_{$endpoint}_{$param_hash}";
    }

    private function get_cache_ttl(): int {
        return (int) $this->settings->get('cache_ttl', 300); // Default 5 minutes
    }
}

2.4 Cache Class

<?php

class Cannabrands_Cache {

    const CACHE_GROUP = 'cannabrands_menu';

    /**
     * Get cached value
     *
     * @param string $key
     * @return mixed|false
     */
    public function get(string $key) {
        // Try object cache first (if available)
        if (wp_using_ext_object_cache()) {
            return wp_cache_get($key, self::CACHE_GROUP);
        }

        // Fall back to transients
        return get_transient($key);
    }

    /**
     * Set cached value
     *
     * @param string $key
     * @param mixed $value
     * @param int $ttl Seconds
     * @return bool
     */
    public function set(string $key, $value, int $ttl = 300): bool {
        // Try object cache first
        if (wp_using_ext_object_cache()) {
            return wp_cache_set($key, $value, self::CACHE_GROUP, $ttl);
        }

        // Fall back to transients
        return set_transient($key, $value, $ttl);
    }

    /**
     * Delete cached value
     *
     * @param string $key
     * @return bool
     */
    public function delete(string $key): bool {
        if (wp_using_ext_object_cache()) {
            return wp_cache_delete($key, self::CACHE_GROUP);
        }

        return delete_transient($key);
    }

    /**
     * Clear all plugin cache
     */
    public function clear_all(): void {
        global $wpdb;

        // Clear transients matching our prefix
        $wpdb->query(
            "DELETE FROM {$wpdb->options}
             WHERE option_name LIKE '_transient_cannabrands_%'
                OR option_name LIKE '_transient_timeout_cannabrands_%'"
        );

        // If object cache, try to flush our group
        if (wp_using_ext_object_cache() && function_exists('wp_cache_flush_group')) {
            wp_cache_flush_group(self::CACHE_GROUP);
        }
    }

    /**
     * Get or set - fetch from cache or execute callback
     *
     * @param string $key
     * @param callable $callback
     * @param int $ttl
     * @return mixed
     */
    public function remember(string $key, callable $callback, int $ttl = 300) {
        $cached = $this->get($key);

        if ($cached !== false) {
            return $cached;
        }

        $value = $callback();

        if (!is_wp_error($value)) {
            $this->set($key, $value, $ttl);
        }

        return $value;
    }
}

2.5 Settings Class

<?php

class Cannabrands_Settings {

    const OPTION_NAME = 'cannabrands_menu_settings';

    private $settings;

    public function __construct() {
        $this->settings = get_option(self::OPTION_NAME, $this->get_defaults());

        add_action('admin_menu', [$this, 'add_menu_page']);
        add_action('admin_init', [$this, 'register_settings']);
    }

    private function get_defaults(): array {
        return [
            'api_base_url' => 'https://api.cannabrands.app',
            'store_key' => '',
            'api_key' => '',
            'cache_ttl' => 300,
            'default_image' => '',
            'enable_dutchie_links' => true,
            'price_currency' => 'USD',
            'price_position' => 'before', // before or after
        ];
    }

    public function get(string $key, $default = null) {
        return isset($this->settings[$key]) ? $this->settings[$key] : $default;
    }

    public function add_menu_page() {
        add_options_page(
            __('Cannabrands Menu Settings', 'cannabrands-menu'),
            __('Cannabrands Menu', 'cannabrands-menu'),
            'manage_options',
            'cannabrands-menu',
            [$this, 'render_settings_page']
        );
    }

    public function register_settings() {
        register_setting(self::OPTION_NAME, self::OPTION_NAME, [
            'sanitize_callback' => [$this, 'sanitize_settings'],
        ]);

        // API Settings Section
        add_settings_section(
            'cannabrands_api_section',
            __('API Configuration', 'cannabrands-menu'),
            [$this, 'render_api_section'],
            'cannabrands-menu'
        );

        add_settings_field(
            'api_base_url',
            __('API Base URL', 'cannabrands-menu'),
            [$this, 'render_text_field'],
            'cannabrands-menu',
            'cannabrands_api_section',
            ['field' => 'api_base_url', 'description' => 'Usually https://api.cannabrands.app']
        );

        add_settings_field(
            'store_key',
            __('Store ID / Slug', 'cannabrands-menu'),
            [$this, 'render_text_field'],
            'cannabrands-menu',
            'cannabrands_api_section',
            ['field' => 'store_key', 'description' => 'Your store identifier (e.g., deeply-rooted-sacramento)']
        );

        add_settings_field(
            'api_key',
            __('API Key', 'cannabrands-menu'),
            [$this, 'render_password_field'],
            'cannabrands-menu',
            'cannabrands_api_section',
            ['field' => 'api_key', 'description' => 'Your store API key']
        );

        // Cache Settings Section
        add_settings_section(
            'cannabrands_cache_section',
            __('Cache Settings', 'cannabrands-menu'),
            [$this, 'render_cache_section'],
            'cannabrands-menu'
        );

        add_settings_field(
            'cache_ttl',
            __('Cache Duration (seconds)', 'cannabrands-menu'),
            [$this, 'render_number_field'],
            'cannabrands-menu',
            'cannabrands_cache_section',
            ['field' => 'cache_ttl', 'min' => 60, 'max' => 3600, 'description' => 'How long to cache menu data (300 = 5 minutes)']
        );

        // Display Settings Section
        add_settings_section(
            'cannabrands_display_section',
            __('Display Settings', 'cannabrands-menu'),
            null,
            'cannabrands-menu'
        );

        add_settings_field(
            'enable_dutchie_links',
            __('Enable Dutchie Links', 'cannabrands-menu'),
            [$this, 'render_checkbox_field'],
            'cannabrands-menu',
            'cannabrands_display_section',
            ['field' => 'enable_dutchie_links', 'label' => 'Link products to Dutchie product pages']
        );
    }

    public function render_settings_page() {
        include CANNABRANDS_MENU_PATH . 'templates/admin/settings-page.php';
    }

    public function render_api_section() {
        echo '<p>' . __('Enter your Cannabrands API credentials. Contact support@cannabrands.app if you need API access.', 'cannabrands-menu') . '</p>';
    }

    public function render_cache_section() {
        echo '<p>' . __('Menu data is cached to improve performance. Click "Clear Cache" to fetch fresh data.', 'cannabrands-menu') . '</p>';
        echo '<button type="button" class="button" id="cannabrands-clear-cache">' . __('Clear Cache', 'cannabrands-menu') . '</button>';
        echo '<span id="cannabrands-cache-status" style="margin-left: 10px;"></span>';
    }

    public function render_text_field($args) {
        $field = $args['field'];
        $value = esc_attr($this->get($field, ''));
        $description = isset($args['description']) ? $args['description'] : '';

        echo "<input type='text' name='" . self::OPTION_NAME . "[{$field}]' value='{$value}' class='regular-text' />";
        if ($description) {
            echo "<p class='description'>{$description}</p>";
        }
    }

    public function render_password_field($args) {
        $field = $args['field'];
        $value = esc_attr($this->get($field, ''));
        $description = isset($args['description']) ? $args['description'] : '';

        echo "<input type='password' name='" . self::OPTION_NAME . "[{$field}]' value='{$value}' class='regular-text' />";
        if ($description) {
            echo "<p class='description'>{$description}</p>";
        }
    }

    public function render_number_field($args) {
        $field = $args['field'];
        $value = esc_attr($this->get($field, ''));
        $min = isset($args['min']) ? $args['min'] : 0;
        $max = isset($args['max']) ? $args['max'] : 9999;
        $description = isset($args['description']) ? $args['description'] : '';

        echo "<input type='number' name='" . self::OPTION_NAME . "[{$field}]' value='{$value}' min='{$min}' max='{$max}' class='small-text' />";
        if ($description) {
            echo "<p class='description'>{$description}</p>";
        }
    }

    public function render_checkbox_field($args) {
        $field = $args['field'];
        $checked = $this->get($field) ? 'checked' : '';
        $label = isset($args['label']) ? $args['label'] : '';

        echo "<label><input type='checkbox' name='" . self::OPTION_NAME . "[{$field}]' value='1' {$checked} /> {$label}</label>";
    }

    public function sanitize_settings($input) {
        $sanitized = [];

        $sanitized['api_base_url'] = esc_url_raw($input['api_base_url'] ?? '');
        $sanitized['store_key'] = sanitize_text_field($input['store_key'] ?? '');
        $sanitized['api_key'] = sanitize_text_field($input['api_key'] ?? '');
        $sanitized['cache_ttl'] = absint($input['cache_ttl'] ?? 300);
        $sanitized['enable_dutchie_links'] = isset($input['enable_dutchie_links']) ? 1 : 0;

        // Validate cache TTL range
        if ($sanitized['cache_ttl'] < 60) $sanitized['cache_ttl'] = 60;
        if ($sanitized['cache_ttl'] > 3600) $sanitized['cache_ttl'] = 3600;

        return $sanitized;
    }
}

3. Shortcodes

3.1 Shortcode Registration

<?php

class Cannabrands_Shortcodes {

    /** @var Cannabrands_Api_Client */
    private $api;

    public function __construct(Cannabrands_Api_Client $api) {
        $this->api = $api;

        add_shortcode('cannabrands_menu', [$this, 'render_menu']);
        add_shortcode('cannabrands_specials', [$this, 'render_specials']);
        add_shortcode('cannabrands_carousel', [$this, 'render_carousel']);
        add_shortcode('cannabrands_categories', [$this, 'render_categories']);
        add_shortcode('cannabrands_product', [$this, 'render_product']);
    }

    /**
     * [cannabrands_menu]
     *
     * Attributes:
     *   category     - Filter by category slug (e.g., "flower", "vapes")
     *   brand        - Filter by brand name
     *   sort         - Sort order: name, price_asc, price_desc, thc_desc, newest
     *   limit        - Max products to show (default: 100)
     *   layout       - Display: grid, list, grouped (default: grid)
     *   columns      - Grid columns: 2, 3, 4, 5 (default: 4)
     *   show_price   - Show price: yes/no (default: yes)
     *   show_thc     - Show THC%: yes/no (default: yes)
     *   show_stock   - Show stock status: yes/no (default: yes)
     *   in_stock     - Only show in-stock: yes/no (default: yes)
     */
    public function render_menu($atts): string {
        $atts = shortcode_atts([
            'category' => '',
            'brand' => '',
            'sort' => 'name',
            'limit' => 100,
            'layout' => 'grid',
            'columns' => 4,
            'show_price' => 'yes',
            'show_thc' => 'yes',
            'show_stock' => 'yes',
            'in_stock' => 'yes',
        ], $atts, 'cannabrands_menu');

        $params = [
            'sort' => sanitize_text_field($atts['sort']),
            'limit' => absint($atts['limit']),
            'in_stock' => $atts['in_stock'] === 'yes',
            'group_by_category' => $atts['layout'] === 'grouped',
        ];

        if (!empty($atts['category'])) {
            $params['category'] = sanitize_text_field($atts['category']);
        }

        if (!empty($atts['brand'])) {
            $params['brand'] = sanitize_text_field($atts['brand']);
        }

        $data = $this->api->get_menu($params);

        if (is_wp_error($data)) {
            return $this->render_error($data);
        }

        $template = $atts['layout'] === 'grouped' ? 'menu-grouped' : 'menu-grid';

        return $this->load_template("menu/{$template}", [
            'data' => $data,
            'atts' => $atts,
        ]);
    }

    /**
     * [cannabrands_specials]
     *
     * Attributes:
     *   category     - Filter by category slug
     *   brand        - Filter by brand name
     *   type         - Special type: percent_off, dollar_off, bogo, bundle
     *   limit        - Max products (default: 10)
     *   layout       - Display: grid, list, banner (default: grid)
     *   columns      - Grid columns (default: 4)
     *   show_savings - Show savings amount: yes/no (default: yes)
     */
    public function render_specials($atts): string {
        $atts = shortcode_atts([
            'category' => '',
            'brand' => '',
            'type' => '',
            'limit' => 10,
            'layout' => 'grid',
            'columns' => 4,
            'show_savings' => 'yes',
        ], $atts, 'cannabrands_specials');

        $params = [
            'limit' => absint($atts['limit']),
        ];

        if (!empty($atts['category'])) {
            $params['category'] = sanitize_text_field($atts['category']);
        }

        if (!empty($atts['brand'])) {
            $params['brand'] = sanitize_text_field($atts['brand']);
        }

        if (!empty($atts['type'])) {
            $params['special_type'] = sanitize_text_field($atts['type']);
        }

        $data = $this->api->get_specials($params);

        if (is_wp_error($data)) {
            return $this->render_error($data);
        }

        $template = $atts['layout'] === 'banner' ? 'specials-banner' : 'specials-grid';

        return $this->load_template("specials/{$template}", [
            'data' => $data,
            'atts' => $atts,
        ]);
    }

    /**
     * [cannabrands_carousel]
     *
     * Attributes:
     *   category      - Filter by category slug
     *   brand         - Filter by brand name
     *   specials_only - Only show specials: yes/no (default: no)
     *   limit         - Max products (default: 12)
     *   visible       - Visible items at once (default: 4)
     *   autoplay      - Enable autoplay: yes/no (default: no)
     *   speed         - Autoplay speed in ms (default: 3000)
     *   show_arrows   - Show nav arrows: yes/no (default: yes)
     *   show_dots     - Show pagination dots: yes/no (default: yes)
     */
    public function render_carousel($atts): string {
        $atts = shortcode_atts([
            'category' => '',
            'brand' => '',
            'specials_only' => 'no',
            'limit' => 12,
            'visible' => 4,
            'autoplay' => 'no',
            'speed' => 3000,
            'show_arrows' => 'yes',
            'show_dots' => 'yes',
        ], $atts, 'cannabrands_carousel');

        if ($atts['specials_only'] === 'yes') {
            $params = ['limit' => absint($atts['limit'])];

            if (!empty($atts['category'])) {
                $params['category'] = sanitize_text_field($atts['category']);
            }

            if (!empty($atts['brand'])) {
                $params['brand'] = sanitize_text_field($atts['brand']);
            }

            $data = $this->api->get_specials($params);
            $products = isset($data['specials']) ? $data['specials'] : [];
        } else {
            $params = [
                'limit' => absint($atts['limit']),
                'in_stock' => true,
            ];

            if (!empty($atts['category'])) {
                $params['category'] = sanitize_text_field($atts['category']);
            }

            if (!empty($atts['brand'])) {
                $params['brand'] = sanitize_text_field($atts['brand']);
            }

            $data = $this->api->get_menu($params);
            $products = isset($data['products']) ? $data['products'] : [];
        }

        if (is_wp_error($data)) {
            return $this->render_error($data);
        }

        return $this->load_template('carousel/carousel', [
            'products' => $products,
            'atts' => $atts,
        ]);
    }

    /**
     * [cannabrands_categories]
     *
     * Attributes:
     *   layout       - Display: grid, list, dropdown (default: grid)
     *   columns      - Grid columns (default: 4)
     *   show_count   - Show product count: yes/no (default: yes)
     *   show_icons   - Show category icons: yes/no (default: yes)
     *   link_to      - Link behavior: page, filter, none (default: filter)
     */
    public function render_categories($atts): string {
        $atts = shortcode_atts([
            'layout' => 'grid',
            'columns' => 4,
            'show_count' => 'yes',
            'show_icons' => 'yes',
            'link_to' => 'filter',
        ], $atts, 'cannabrands_categories');

        $data = $this->api->get_categories();

        if (is_wp_error($data)) {
            return $this->render_error($data);
        }

        return $this->load_template('categories/categories-grid', [
            'data' => $data,
            'atts' => $atts,
        ]);
    }

    /**
     * [cannabrands_product slug="..."]
     *
     * Attributes:
     *   slug         - Product slug (required)
     *   layout       - Display: card, full, compact (default: card)
     */
    public function render_product($atts): string {
        $atts = shortcode_atts([
            'slug' => '',
            'layout' => 'card',
        ], $atts, 'cannabrands_product');

        if (empty($atts['slug'])) {
            return '<p class="cannabrands-error">' . __('Product slug is required', 'cannabrands-menu') . '</p>';
        }

        $data = $this->api->get_product($atts['slug']);

        if (is_wp_error($data)) {
            return $this->render_error($data);
        }

        return $this->load_template('partials/product-card', [
            'product' => $data['product'],
            'layout' => $atts['layout'],
        ]);
    }

    private function load_template(string $template, array $vars = []): string {
        $template_path = CANNABRANDS_MENU_PATH . 'templates/' . $template . '.php';

        // Allow theme override
        $theme_template = locate_template('cannabrands-menu/' . $template . '.php');
        if ($theme_template) {
            $template_path = $theme_template;
        }

        if (!file_exists($template_path)) {
            return '<!-- Template not found: ' . esc_html($template) . ' -->';
        }

        extract($vars);

        ob_start();
        include $template_path;
        return ob_get_clean();
    }

    private function render_error(WP_Error $error): string {
        if (current_user_can('manage_options')) {
            return '<div class="cannabrands-error cannabrands-error-admin">' .
                   '<strong>Cannabrands Menu Error:</strong> ' .
                   esc_html($error->get_error_message()) .
                   '</div>';
        }

        return '<!-- Cannabrands Menu: ' . esc_html($error->get_error_code()) . ' -->';
    }
}

3.2 Shortcode Summary Table

Shortcode Purpose Key Attributes
[cannabrands_menu] Full product menu category, brand, sort, limit, layout, columns
[cannabrands_specials] Products on special category, brand, type, limit, layout
[cannabrands_carousel] Horizontal product slider category, specials_only, limit, visible, autoplay
[cannabrands_categories] Category list/grid layout, columns, show_count, link_to
[cannabrands_product] Single product card slug, layout

3.3 Example Shortcode Usage

<!-- Full menu with default settings -->
[cannabrands_menu]

<!-- Flower category only, sorted by THC -->
[cannabrands_menu category="flower" sort="thc_desc" limit="50"]

<!-- Pre-rolls from Raw Garden -->
[cannabrands_menu category="pre-rolls" brand="Raw Garden" columns="3"]

<!-- Today's specials -->
[cannabrands_specials limit="10" show_savings="yes"]

<!-- BOGO deals only -->
[cannabrands_specials type="bogo" layout="banner"]

<!-- Featured carousel with autoplay -->
[cannabrands_carousel category="concentrates" limit="8" autoplay="yes" speed="4000"]

<!-- Specials carousel -->
[cannabrands_carousel specials_only="yes" limit="6" visible="3"]

<!-- Category navigation -->
[cannabrands_categories layout="grid" columns="4" show_icons="yes"]

<!-- Single product embed -->
[cannabrands_product slug="raw-garden-live-resin-slurm-og-1g"]

4. Elementor Widgets

4.1 Elementor Integration Class

<?php

class Cannabrands_Elementor {

    /** @var Cannabrands_Api_Client */
    private $api;

    public function __construct(Cannabrands_Api_Client $api) {
        $this->api = $api;

        add_action('elementor/widgets/register', [$this, 'register_widgets']);
        add_action('elementor/elements/categories_registered', [$this, 'register_category']);
        add_action('elementor/editor/after_enqueue_styles', [$this, 'editor_styles']);
    }

    public function register_category($elements_manager) {
        $elements_manager->add_category('cannabrands', [
            'title' => __('Cannabrands Menu', 'cannabrands-menu'),
            'icon' => 'fa fa-cannabis',
        ]);
    }

    public function register_widgets($widgets_manager) {
        require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-menu.php';
        require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-specials.php';
        require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-carousel.php';
        require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-categories.php';

        $widgets_manager->register(new Cannabrands_Widget_Menu($this->api));
        $widgets_manager->register(new Cannabrands_Widget_Specials($this->api));
        $widgets_manager->register(new Cannabrands_Widget_Carousel($this->api));
        $widgets_manager->register(new Cannabrands_Widget_Categories($this->api));
    }

    public function editor_styles() {
        wp_enqueue_style(
            'cannabrands-elementor-editor',
            CANNABRANDS_MENU_URL . 'assets/css/cannabrands-menu-elementor.css',
            [],
            CANNABRANDS_MENU_VERSION
        );
    }
}

4.2 Menu Widget

<?php

use Elementor\Widget_Base;
use Elementor\Controls_Manager;
use Elementor\Group_Control_Typography;

class Cannabrands_Widget_Menu extends Widget_Base {

    private $api;

    public function __construct($data = [], $args = null, $api = null) {
        parent::__construct($data, $args);
        $this->api = $api ?? cannabrands_menu()->api;
    }

    public function get_name() {
        return 'cannabrands_menu';
    }

    public function get_title() {
        return __('Product Menu', 'cannabrands-menu');
    }

    public function get_icon() {
        return 'eicon-posts-grid';
    }

    public function get_categories() {
        return ['cannabrands'];
    }

    public function get_keywords() {
        return ['menu', 'products', 'cannabis', 'dispensary'];
    }

    protected function register_controls() {

        // ========== CONTENT TAB ==========

        $this->start_controls_section('section_content', [
            'label' => __('Content', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        // Category filter
        $this->add_control('category', [
            'label' => __('Category', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => '',
            'options' => $this->get_category_options(),
            'description' => __('Filter products by category', 'cannabrands-menu'),
        ]);

        // Brand filter
        $this->add_control('brand', [
            'label' => __('Brand', 'cannabrands-menu'),
            'type' => Controls_Manager::TEXT,
            'default' => '',
            'placeholder' => __('e.g., Raw Garden', 'cannabrands-menu'),
            'description' => __('Filter products by brand name', 'cannabrands-menu'),
        ]);

        // Sort order
        $this->add_control('sort', [
            'label' => __('Sort By', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => 'name',
            'options' => [
                'name' => __('Name (A-Z)', 'cannabrands-menu'),
                'price_asc' => __('Price (Low to High)', 'cannabrands-menu'),
                'price_desc' => __('Price (High to Low)', 'cannabrands-menu'),
                'thc_desc' => __('THC (High to Low)', 'cannabrands-menu'),
                'newest' => __('Newest First', 'cannabrands-menu'),
            ],
        ]);

        // Limit
        $this->add_control('limit', [
            'label' => __('Products to Show', 'cannabrands-menu'),
            'type' => Controls_Manager::NUMBER,
            'default' => 50,
            'min' => 1,
            'max' => 500,
        ]);

        // In stock only
        $this->add_control('in_stock_only', [
            'label' => __('In Stock Only', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->end_controls_section();

        // ========== LAYOUT SECTION ==========

        $this->start_controls_section('section_layout', [
            'label' => __('Layout', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        // Layout type
        $this->add_control('layout', [
            'label' => __('Layout', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => 'grid',
            'options' => [
                'grid' => __('Grid', 'cannabrands-menu'),
                'list' => __('List', 'cannabrands-menu'),
                'grouped' => __('Grouped by Category', 'cannabrands-menu'),
            ],
        ]);

        // Columns
        $this->add_responsive_control('columns', [
            'label' => __('Columns', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => '4',
            'tablet_default' => '3',
            'mobile_default' => '2',
            'options' => [
                '1' => '1',
                '2' => '2',
                '3' => '3',
                '4' => '4',
                '5' => '5',
                '6' => '6',
            ],
            'condition' => [
                'layout' => ['grid', 'grouped'],
            ],
            'selectors' => [
                '{{WRAPPER}} .cannabrands-menu-grid' => 'grid-template-columns: repeat({{VALUE}}, 1fr);',
            ],
        ]);

        // Gap
        $this->add_responsive_control('gap', [
            'label' => __('Gap', 'cannabrands-menu'),
            'type' => Controls_Manager::SLIDER,
            'size_units' => ['px', 'em'],
            'default' => ['size' => 20, 'unit' => 'px'],
            'selectors' => [
                '{{WRAPPER}} .cannabrands-menu-grid' => 'gap: {{SIZE}}{{UNIT}};',
            ],
        ]);

        $this->end_controls_section();

        // ========== CARD CONTENT SECTION ==========

        $this->start_controls_section('section_card_content', [
            'label' => __('Card Content', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        $this->add_control('show_image', [
            'label' => __('Show Image', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_brand', [
            'label' => __('Show Brand', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_category', [
            'label' => __('Show Category', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_price', [
            'label' => __('Show Price', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_thc', [
            'label' => __('Show THC %', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_strain_type', [
            'label' => __('Show Strain Type', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_stock_badge', [
            'label' => __('Show Stock Badge', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_special_badge', [
            'label' => __('Show Special Badge', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->end_controls_section();

        // ========== STYLE TAB ==========

        $this->start_controls_section('section_card_style', [
            'label' => __('Card Style', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_STYLE,
        ]);

        $this->add_control('card_background', [
            'label' => __('Background Color', 'cannabrands-menu'),
            'type' => Controls_Manager::COLOR,
            'selectors' => [
                '{{WRAPPER}} .cannabrands-product-card' => 'background-color: {{VALUE}};',
            ],
        ]);

        $this->add_control('card_border_radius', [
            'label' => __('Border Radius', 'cannabrands-menu'),
            'type' => Controls_Manager::DIMENSIONS,
            'size_units' => ['px', '%'],
            'selectors' => [
                '{{WRAPPER}} .cannabrands-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}} .cannabrands-product-card',
        ]);

        $this->end_controls_section();

        // Typography section
        $this->start_controls_section('section_typography', [
            'label' => __('Typography', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_STYLE,
        ]);

        $this->add_group_control(Group_Control_Typography::get_type(), [
            'name' => 'product_name_typography',
            'label' => __('Product Name', 'cannabrands-menu'),
            'selector' => '{{WRAPPER}} .cannabrands-product-name',
        ]);

        $this->add_group_control(Group_Control_Typography::get_type(), [
            'name' => 'price_typography',
            'label' => __('Price', 'cannabrands-menu'),
            'selector' => '{{WRAPPER}} .cannabrands-product-price',
        ]);

        $this->end_controls_section();
    }

    protected function render() {
        $settings = $this->get_settings_for_display();

        $params = [
            'sort' => $settings['sort'],
            'limit' => $settings['limit'],
            'in_stock' => $settings['in_stock_only'] === 'yes',
            'group_by_category' => $settings['layout'] === 'grouped',
        ];

        if (!empty($settings['category'])) {
            $params['category'] = $settings['category'];
        }

        if (!empty($settings['brand'])) {
            $params['brand'] = $settings['brand'];
        }

        $data = $this->api->get_menu($params);

        if (is_wp_error($data)) {
            if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
                echo '<div class="cannabrands-error">' . esc_html($data->get_error_message()) . '</div>';
            }
            return;
        }

        $products = isset($data['products']) ? $data['products'] : [];

        if (empty($products) && \Elementor\Plugin::$instance->editor->is_edit_mode()) {
            echo '<div class="cannabrands-empty">' . __('No products found. Adjust your filters or check API settings.', 'cannabrands-menu') . '</div>';
            return;
        }

        include CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/views/menu-view.php';
    }

    private function get_category_options(): array {
        $options = ['' => __('All Categories', 'cannabrands-menu')];

        $data = $this->api->get_categories();

        if (!is_wp_error($data) && isset($data['categories'])) {
            foreach ($data['categories'] as $cat) {
                $options[$cat['slug']] = $cat['name'];

                // Add children with indentation
                if (!empty($cat['children'])) {
                    foreach ($cat['children'] as $child) {
                        $options[$child['slug']] = '— ' . $child['name'];
                    }
                }
            }
        }

        return $options;
    }
}

4.3 Specials Widget

<?php

use Elementor\Widget_Base;
use Elementor\Controls_Manager;

class Cannabrands_Widget_Specials extends Widget_Base {

    private $api;

    public function __construct($data = [], $args = null, $api = null) {
        parent::__construct($data, $args);
        $this->api = $api ?? cannabrands_menu()->api;
    }

    public function get_name() {
        return 'cannabrands_specials';
    }

    public function get_title() {
        return __('Specials & Deals', 'cannabrands-menu');
    }

    public function get_icon() {
        return 'eicon-price-table';
    }

    public function get_categories() {
        return ['cannabrands'];
    }

    protected function register_controls() {

        $this->start_controls_section('section_content', [
            'label' => __('Content', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        // Heading
        $this->add_control('heading', [
            'label' => __('Heading', 'cannabrands-menu'),
            'type' => Controls_Manager::TEXT,
            'default' => __("Today's Specials", 'cannabrands-menu'),
        ]);

        // Category filter
        $this->add_control('category', [
            'label' => __('Category', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => '',
            'options' => $this->get_category_options(),
        ]);

        // Brand filter
        $this->add_control('brand', [
            'label' => __('Brand', 'cannabrands-menu'),
            'type' => Controls_Manager::TEXT,
            'default' => '',
        ]);

        // Special type filter
        $this->add_control('special_type', [
            'label' => __('Deal Type', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => '',
            'options' => [
                '' => __('All Types', 'cannabrands-menu'),
                'percent_off' => __('Percent Off', 'cannabrands-menu'),
                'dollar_off' => __('Dollar Off', 'cannabrands-menu'),
                'bogo' => __('BOGO', 'cannabrands-menu'),
                'bundle' => __('Bundle Deals', 'cannabrands-menu'),
            ],
        ]);

        // Limit
        $this->add_control('limit', [
            'label' => __('Products to Show', 'cannabrands-menu'),
            'type' => Controls_Manager::NUMBER,
            'default' => 8,
            'min' => 1,
            'max' => 50,
        ]);

        $this->end_controls_section();

        // Layout section
        $this->start_controls_section('section_layout', [
            'label' => __('Layout', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        $this->add_control('layout', [
            'label' => __('Layout', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => 'grid',
            'options' => [
                'grid' => __('Grid', 'cannabrands-menu'),
                'list' => __('List', 'cannabrands-menu'),
                'banner' => __('Banner Style', 'cannabrands-menu'),
            ],
        ]);

        $this->add_responsive_control('columns', [
            'label' => __('Columns', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => '4',
            'options' => ['1' => '1', '2' => '2', '3' => '3', '4' => '4', '5' => '5'],
            'condition' => ['layout' => 'grid'],
            'selectors' => [
                '{{WRAPPER}} .cannabrands-specials-grid' => 'grid-template-columns: repeat({{VALUE}}, 1fr);',
            ],
        ]);

        $this->end_controls_section();

        // Display options
        $this->start_controls_section('section_display', [
            'label' => __('Display Options', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        $this->add_control('show_original_price', [
            'label' => __('Show Original Price (Strikethrough)', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_savings', [
            'label' => __('Show Savings Amount', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_deal_badge', [
            'label' => __('Show Deal Badge', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_deal_text', [
            'label' => __('Show Deal Description', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->end_controls_section();

        // Badge styling
        $this->start_controls_section('section_badge_style', [
            'label' => __('Deal Badge', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_STYLE,
        ]);

        $this->add_control('badge_background', [
            'label' => __('Badge Background', 'cannabrands-menu'),
            'type' => Controls_Manager::COLOR,
            'default' => '#ef4444',
            'selectors' => [
                '{{WRAPPER}} .cannabrands-special-badge' => 'background-color: {{VALUE}};',
            ],
        ]);

        $this->add_control('badge_color', [
            'label' => __('Badge Text Color', 'cannabrands-menu'),
            'type' => Controls_Manager::COLOR,
            'default' => '#ffffff',
            'selectors' => [
                '{{WRAPPER}} .cannabrands-special-badge' => 'color: {{VALUE}};',
            ],
        ]);

        $this->end_controls_section();
    }

    protected function render() {
        $settings = $this->get_settings_for_display();

        $params = [
            'limit' => $settings['limit'],
        ];

        if (!empty($settings['category'])) {
            $params['category'] = $settings['category'];
        }

        if (!empty($settings['brand'])) {
            $params['brand'] = $settings['brand'];
        }

        if (!empty($settings['special_type'])) {
            $params['special_type'] = $settings['special_type'];
        }

        $data = $this->api->get_specials($params);

        if (is_wp_error($data)) {
            if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
                echo '<div class="cannabrands-error">' . esc_html($data->get_error_message()) . '</div>';
            }
            return;
        }

        $specials = isset($data['specials']) ? $data['specials'] : [];

        include CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/views/specials-view.php';
    }

    private function get_category_options(): array {
        // Same implementation as Menu widget
        return ['' => __('All Categories', 'cannabrands-menu')];
    }
}
<?php

use Elementor\Widget_Base;
use Elementor\Controls_Manager;

class Cannabrands_Widget_Carousel extends Widget_Base {

    private $api;

    public function __construct($data = [], $args = null, $api = null) {
        parent::__construct($data, $args);
        $this->api = $api ?? cannabrands_menu()->api;
    }

    public function get_name() {
        return 'cannabrands_carousel';
    }

    public function get_title() {
        return __('Product Carousel', 'cannabrands-menu');
    }

    public function get_icon() {
        return 'eicon-slider-push';
    }

    public function get_categories() {
        return ['cannabrands'];
    }

    public function get_script_depends() {
        return ['cannabrands-carousel'];
    }

    protected function register_controls() {

        // Content section
        $this->start_controls_section('section_content', [
            'label' => __('Content', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        $this->add_control('heading', [
            'label' => __('Heading', 'cannabrands-menu'),
            'type' => Controls_Manager::TEXT,
            'default' => __('Featured Products', 'cannabrands-menu'),
        ]);

        $this->add_control('source', [
            'label' => __('Product Source', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => 'all',
            'options' => [
                'all' => __('All Products', 'cannabrands-menu'),
                'specials' => __('Specials Only', 'cannabrands-menu'),
                'category' => __('Specific Category', 'cannabrands-menu'),
                'brand' => __('Specific Brand', 'cannabrands-menu'),
            ],
        ]);

        $this->add_control('category', [
            'label' => __('Category', 'cannabrands-menu'),
            'type' => Controls_Manager::SELECT,
            'default' => '',
            'options' => $this->get_category_options(),
            'condition' => ['source' => ['category', 'all']],
        ]);

        $this->add_control('brand', [
            'label' => __('Brand', 'cannabrands-menu'),
            'type' => Controls_Manager::TEXT,
            'default' => '',
            'condition' => ['source' => ['brand', 'all']],
        ]);

        $this->add_control('limit', [
            'label' => __('Products to Load', 'cannabrands-menu'),
            'type' => Controls_Manager::NUMBER,
            'default' => 12,
            'min' => 4,
            'max' => 24,
        ]);

        $this->end_controls_section();

        // Carousel settings
        $this->start_controls_section('section_carousel', [
            'label' => __('Carousel Settings', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        $this->add_responsive_control('slides_to_show', [
            'label' => __('Slides to Show', 'cannabrands-menu'),
            'type' => Controls_Manager::NUMBER,
            'default' => 4,
            'tablet_default' => 3,
            'mobile_default' => 1,
            'min' => 1,
            'max' => 6,
        ]);

        $this->add_control('autoplay', [
            'label' => __('Autoplay', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => '',
        ]);

        $this->add_control('autoplay_speed', [
            'label' => __('Autoplay Speed (ms)', 'cannabrands-menu'),
            'type' => Controls_Manager::NUMBER,
            'default' => 3000,
            'min' => 1000,
            'max' => 10000,
            'step' => 500,
            'condition' => ['autoplay' => 'yes'],
        ]);

        $this->add_control('infinite', [
            'label' => __('Infinite Loop', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_arrows', [
            'label' => __('Show Arrows', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_dots', [
            'label' => __('Show Dots', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->end_controls_section();

        // Card content options
        $this->start_controls_section('section_card', [
            'label' => __('Card Content', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_CONTENT,
        ]);

        $this->add_control('show_brand', [
            'label' => __('Show Brand', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_price', [
            'label' => __('Show Price', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->add_control('show_thc', [
            'label' => __('Show THC', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => '',
        ]);

        $this->add_control('show_special_badge', [
            'label' => __('Show Special Badge', 'cannabrands-menu'),
            'type' => Controls_Manager::SWITCHER,
            'default' => 'yes',
        ]);

        $this->end_controls_section();

        // Arrow styling
        $this->start_controls_section('section_arrows_style', [
            'label' => __('Navigation Arrows', 'cannabrands-menu'),
            'tab' => Controls_Manager::TAB_STYLE,
            'condition' => ['show_arrows' => 'yes'],
        ]);

        $this->add_control('arrow_color', [
            'label' => __('Arrow Color', 'cannabrands-menu'),
            'type' => Controls_Manager::COLOR,
            'selectors' => [
                '{{WRAPPER}} .cannabrands-carousel-arrow' => 'color: {{VALUE}};',
            ],
        ]);

        $this->add_control('arrow_background', [
            'label' => __('Arrow Background', 'cannabrands-menu'),
            'type' => Controls_Manager::COLOR,
            'selectors' => [
                '{{WRAPPER}} .cannabrands-carousel-arrow' => 'background-color: {{VALUE}};',
            ],
        ]);

        $this->end_controls_section();
    }

    protected function render() {
        $settings = $this->get_settings_for_display();

        // Determine which API to call based on source
        if ($settings['source'] === 'specials') {
            $params = ['limit' => $settings['limit']];

            if (!empty($settings['category'])) {
                $params['category'] = $settings['category'];
            }

            $data = $this->api->get_specials($params);
            $products = isset($data['specials']) ? $data['specials'] : [];
        } else {
            $params = [
                'limit' => $settings['limit'],
                'in_stock' => true,
            ];

            if (!empty($settings['category'])) {
                $params['category'] = $settings['category'];
            }

            if (!empty($settings['brand'])) {
                $params['brand'] = $settings['brand'];
            }

            $data = $this->api->get_menu($params);
            $products = isset($data['products']) ? $data['products'] : [];
        }

        if (is_wp_error($data)) {
            if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
                echo '<div class="cannabrands-error">' . esc_html($data->get_error_message()) . '</div>';
            }
            return;
        }

        // Build carousel config for JS
        $carousel_config = [
            'slidesToShow' => (int) $settings['slides_to_show'],
            'slidesToShowTablet' => (int) ($settings['slides_to_show_tablet'] ?? 3),
            'slidesToShowMobile' => (int) ($settings['slides_to_show_mobile'] ?? 1),
            'autoplay' => $settings['autoplay'] === 'yes',
            'autoplaySpeed' => (int) $settings['autoplay_speed'],
            'infinite' => $settings['infinite'] === 'yes',
            'arrows' => $settings['show_arrows'] === 'yes',
            'dots' => $settings['show_dots'] === 'yes',
        ];

        include CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/views/carousel-view.php';
    }

    private function get_category_options(): array {
        return ['' => __('All Categories', 'cannabrands-menu')];
    }
}

4.5 Elementor Widget Summary

Widget Controls Data Source Renders
Product Menu Category, Brand, Sort, Limit, Layout (grid/list/grouped), Columns, Card content toggles, Typography, Colors GET /menu Product grid/list with filtering
Specials & Deals Category, Brand, Deal Type, Limit, Layout, Show savings/badges/deal text, Badge styling GET /specials Special products with deal badges
Product Carousel Source (all/specials/category/brand), Slides to show, Autoplay, Speed, Arrows, Dots, Card content GET /menu or GET /specials Horizontal slider
Category List Layout (grid/list/dropdown), Columns, Show count/icons, Link behavior GET /categories Category navigation

5. Caching Strategy

5.1 Cache Layers

┌─────────────────────────────────────────────────────────────────┐
│                     REQUEST FLOW                                 │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  LAYER 1: Object Cache (Redis/Memcached if available)           │
│  TTL: 5 minutes                                                  │
│  Key: cannabrands_{store}_{endpoint}_{params_hash}              │
└─────────────────────────────┬───────────────────────────────────┘
                              │ MISS
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  LAYER 2: WordPress Transients (Database)                       │
│  TTL: 5 minutes                                                  │
│  Fallback when no object cache                                  │
└─────────────────────────────┬───────────────────────────────────┘
                              │ MISS
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  LAYER 3: API Request                                           │
│  Result cached in Layer 1 or 2                                  │
└─────────────────────────────────────────────────────────────────┘

5.2 Cache Key Strategy

// Cache key format
"cannabrands_{store_key}_{endpoint}_{md5(serialized_params)}"

// Examples:
"cannabrands_deeply-rooted_menu_a1b2c3d4"           // Full menu
"cannabrands_deeply-rooted_menu_e5f6g7h8"           // Menu with category filter
"cannabrands_deeply-rooted_specials_i9j0k1l2"       // Specials
"cannabrands_deeply-rooted_categories_m3n4o5p6"     // Categories (less volatile)

5.3 Cache TTL Recommendations

Endpoint TTL Rationale
/menu 5 minutes Products change frequently (price, stock)
/specials 5 minutes Deals may be time-sensitive
/categories 10 minutes Category structure changes rarely
/brands 10 minutes Brand list changes rarely
/product/:slug 5 minutes Individual product details

5.4 Cache Invalidation

Manual Invalidation:

  • Admin settings page "Clear Cache" button
  • Clears all plugin transients

Automatic Invalidation:

  • Transients expire based on TTL
  • No webhook-based invalidation (keep plugin simple)

Optional: Cron-based Prewarming:

// Schedule hourly cache refresh
add_action('cannabrands_cache_prewarm', function() {
    $api = cannabrands_menu()->api;

    // Prewarm common requests
    $api->get_menu(['limit' => 100]);
    $api->get_specials(['limit' => 50]);
    $api->get_categories();
});

// Schedule the event
if (!wp_next_scheduled('cannabrands_cache_prewarm')) {
    wp_schedule_event(time(), 'hourly', 'cannabrands_cache_prewarm');
}

6. Decoupling from Intelligence Layer

6.1 Separation of Concerns

The WordPress plugin uses current state data only:

Plugin Uses Plugin Does NOT Use
products table (current) brand_daily_metrics
categories table brand_promo_daily_metrics
stores table brand_store_events
store_products (current state) store_product_snapshots (history)
Parsed special fields Intelligence aggregations

6.2 API Path Separation

/v1/stores/:store_key/*      → WordPress Plugin (store-scoped, current state)
/v1/brands/:brand_key/*      → Cannabrands App (brand-scoped, analytics)

6.3 No Cross-Contamination

The store-facing endpoints:

  • Do NOT query brand_daily_metrics or any aggregation tables
  • Do NOT expose brand identifiers or intelligence data
  • Are read-only against products + categories + current store_products state
  • Use simple JOIN queries, not analytical window functions

6.4 Example Query (Store Menu)

-- What the /stores/:key/menu endpoint queries:
SELECT
  p.id,
  p.slug,
  p.name,
  p.brand,
  p.description,
  p.thc_percentage,
  p.cbd_percentage,
  p.strain_type,
  p.weight,
  p.price,
  p.original_price,
  p.in_stock,
  p.special_text,
  p.image_url_full,
  p.dutchie_url,
  p.last_seen_at,
  c.id as category_id,
  c.name as category_name,
  c.slug as category_slug
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.store_id = $1
  AND p.in_stock = $2  -- optional filter
  AND c.slug = $3      -- optional filter
ORDER BY p.name ASC
LIMIT $4 OFFSET $5;

No references to:

  • canonical_brands
  • brand_store_presence
  • brand_daily_metrics
  • store_product_snapshots

7. Template Examples

7.1 Product Card Template (templates/partials/product-card.php)

<?php
/**
 * Product card template
 *
 * @var array $product The product data
 * @var array $settings Widget/shortcode settings
 */

$show_image = $settings['show_image'] ?? true;
$show_brand = $settings['show_brand'] ?? true;
$show_price = $settings['show_price'] ?? true;
$show_thc = $settings['show_thc'] ?? true;
$show_special_badge = $settings['show_special_badge'] ?? true;

$image_url = $product['images']['medium'] ?? $product['images']['thumbnail'] ?? '';
$has_special = !empty($product['special']['active']);
$link_url = $product['dutchie_url'] ?? '#';
?>

<div class="cannabrands-product-card" data-product-id="<?php echo esc_attr($product['id']); ?>">

    <?php if ($show_image): ?>
    <div class="cannabrands-product-image">
        <a href="<?php echo esc_url($link_url); ?>" target="_blank" rel="noopener">
            <?php if ($image_url): ?>
                <img src="<?php echo esc_url($image_url); ?>"
                     alt="<?php echo esc_attr($product['name']); ?>"
                     loading="lazy" />
            <?php else: ?>
                <div class="cannabrands-product-placeholder">
                    <span><?php esc_html_e('No Image', 'cannabrands-menu'); ?></span>
                </div>
            <?php endif; ?>
        </a>

        <?php if ($show_special_badge && $has_special): ?>
        <span class="cannabrands-special-badge">
            <?php echo esc_html($product['special']['badge_text'] ?? 'SALE'); ?>
        </span>
        <?php endif; ?>

        <?php if (!empty($product['strain_type'])): ?>
        <span class="cannabrands-strain-badge cannabrands-strain-<?php echo esc_attr($product['strain_type']); ?>">
            <?php echo esc_html(ucfirst($product['strain_type'])); ?>
        </span>
        <?php endif; ?>
    </div>
    <?php endif; ?>

    <div class="cannabrands-product-info">

        <?php if ($show_brand && !empty($product['brand'])): ?>
        <div class="cannabrands-product-brand">
            <?php echo esc_html($product['brand']); ?>
        </div>
        <?php endif; ?>

        <h3 class="cannabrands-product-name">
            <a href="<?php echo esc_url($link_url); ?>" target="_blank" rel="noopener">
                <?php echo esc_html($product['name']); ?>
            </a>
        </h3>

        <?php if (!empty($product['weight'])): ?>
        <div class="cannabrands-product-weight">
            <?php echo esc_html($product['weight']); ?>
        </div>
        <?php endif; ?>

        <?php if ($show_thc && !empty($product['thc_percent'])): ?>
        <div class="cannabrands-product-cannabinoids">
            <span class="cannabrands-thc">THC: <?php echo esc_html($product['thc_percent']); ?>%</span>
            <?php if (!empty($product['cbd_percent'])): ?>
            <span class="cannabrands-cbd">CBD: <?php echo esc_html($product['cbd_percent']); ?>%</span>
            <?php endif; ?>
        </div>
        <?php endif; ?>

        <?php if ($show_price): ?>
        <div class="cannabrands-product-price">
            <?php if ($has_special && !empty($product['price']['regular'])): ?>
                <span class="cannabrands-price-regular">
                    <?php echo esc_html($product['price']['formatted']['regular']); ?>
                </span>
                <span class="cannabrands-price-current cannabrands-price-sale">
                    <?php echo esc_html($product['price']['formatted']['current']); ?>
                </span>
            <?php else: ?>
                <span class="cannabrands-price-current">
                    <?php echo esc_html($product['price']['formatted']['current']); ?>
                </span>
            <?php endif; ?>
        </div>
        <?php endif; ?>

        <?php if (!$product['stock']['in_stock']): ?>
        <div class="cannabrands-product-stock cannabrands-out-of-stock">
            <?php esc_html_e('Out of Stock', 'cannabrands-menu'); ?>
        </div>
        <?php endif; ?>

    </div>
</div>

7.2 Base CSS (assets/css/cannabrands-menu.css)

/* ============================================
   CANNABRANDS MENU - BASE STYLES
   ============================================ */

/* CSS Custom Properties for easy theming */
:root {
    --cannabrands-primary: #10b981;
    --cannabrands-primary-dark: #059669;
    --cannabrands-sale: #ef4444;
    --cannabrands-indica: #6366f1;
    --cannabrands-sativa: #f59e0b;
    --cannabrands-hybrid: #10b981;
    --cannabrands-text: #1f2937;
    --cannabrands-text-muted: #6b7280;
    --cannabrands-border: #e5e7eb;
    --cannabrands-background: #ffffff;
    --cannabrands-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
    --cannabrands-card-radius: 8px;
    --cannabrands-gap: 20px;
}

/* Grid Layout */
.cannabrands-menu-grid,
.cannabrands-specials-grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: var(--cannabrands-gap);
}

@media (max-width: 1024px) {
    .cannabrands-menu-grid,
    .cannabrands-specials-grid {
        grid-template-columns: repeat(3, 1fr);
    }
}

@media (max-width: 768px) {
    .cannabrands-menu-grid,
    .cannabrands-specials-grid {
        grid-template-columns: repeat(2, 1fr);
    }
}

@media (max-width: 480px) {
    .cannabrands-menu-grid,
    .cannabrands-specials-grid {
        grid-template-columns: 1fr;
    }
}

/* Product Card */
.cannabrands-product-card {
    background: var(--cannabrands-background);
    border: 1px solid var(--cannabrands-border);
    border-radius: var(--cannabrands-card-radius);
    overflow: hidden;
    transition: box-shadow 0.2s ease, transform 0.2s ease;
}

.cannabrands-product-card:hover {
    box-shadow: var(--cannabrands-card-shadow);
    transform: translateY(-2px);
}

/* Product Image */
.cannabrands-product-image {
    position: relative;
    aspect-ratio: 1;
    background: #f9fafb;
}

.cannabrands-product-image img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.cannabrands-product-placeholder {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    color: var(--cannabrands-text-muted);
    font-size: 14px;
}

/* Badges */
.cannabrands-special-badge {
    position: absolute;
    top: 8px;
    left: 8px;
    background: var(--cannabrands-sale);
    color: white;
    font-size: 11px;
    font-weight: 600;
    padding: 4px 8px;
    border-radius: 4px;
    text-transform: uppercase;
}

.cannabrands-strain-badge {
    position: absolute;
    top: 8px;
    right: 8px;
    font-size: 10px;
    font-weight: 600;
    padding: 3px 6px;
    border-radius: 3px;
    text-transform: uppercase;
}

.cannabrands-strain-indica { background: var(--cannabrands-indica); color: white; }
.cannabrands-strain-sativa { background: var(--cannabrands-sativa); color: white; }
.cannabrands-strain-hybrid { background: var(--cannabrands-hybrid); color: white; }

/* Product Info */
.cannabrands-product-info {
    padding: 12px;
}

.cannabrands-product-brand {
    font-size: 12px;
    color: var(--cannabrands-text-muted);
    margin-bottom: 4px;
}

.cannabrands-product-name {
    font-size: 14px;
    font-weight: 600;
    color: var(--cannabrands-text);
    margin: 0 0 4px 0;
    line-height: 1.3;
}

.cannabrands-product-name a {
    color: inherit;
    text-decoration: none;
}

.cannabrands-product-name a:hover {
    color: var(--cannabrands-primary);
}

.cannabrands-product-weight {
    font-size: 12px;
    color: var(--cannabrands-text-muted);
    margin-bottom: 8px;
}

.cannabrands-product-cannabinoids {
    font-size: 12px;
    color: var(--cannabrands-text-muted);
    margin-bottom: 8px;
}

.cannabrands-thc {
    font-weight: 500;
    color: var(--cannabrands-primary-dark);
}

.cannabrands-cbd {
    margin-left: 8px;
}

/* Price */
.cannabrands-product-price {
    font-size: 16px;
    font-weight: 700;
}

.cannabrands-price-current {
    color: var(--cannabrands-text);
}

.cannabrands-price-sale {
    color: var(--cannabrands-sale);
}

.cannabrands-price-regular {
    text-decoration: line-through;
    color: var(--cannabrands-text-muted);
    font-weight: 400;
    font-size: 13px;
    margin-right: 6px;
}

/* Stock Status */
.cannabrands-out-of-stock {
    font-size: 11px;
    color: var(--cannabrands-sale);
    font-weight: 500;
    margin-top: 6px;
}

/* Carousel */
.cannabrands-carousel {
    position: relative;
    overflow: hidden;
}

.cannabrands-carousel-track {
    display: flex;
    transition: transform 0.3s ease;
}

.cannabrands-carousel-slide {
    flex: 0 0 25%;
    padding: 0 10px;
    box-sizing: border-box;
}

.cannabrands-carousel-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 40px;
    height: 40px;
    background: var(--cannabrands-background);
    border: 1px solid var(--cannabrands-border);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    z-index: 10;
    transition: background 0.2s;
}

.cannabrands-carousel-arrow:hover {
    background: #f3f4f6;
}

.cannabrands-carousel-prev { left: 0; }
.cannabrands-carousel-next { right: 0; }

.cannabrands-carousel-dots {
    display: flex;
    justify-content: center;
    gap: 8px;
    margin-top: 16px;
}

.cannabrands-carousel-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--cannabrands-border);
    cursor: pointer;
}

.cannabrands-carousel-dot.active {
    background: var(--cannabrands-primary);
}

/* Error States */
.cannabrands-error {
    padding: 20px;
    background: #fef2f2;
    border: 1px solid #fecaca;
    border-radius: var(--cannabrands-card-radius);
    color: #991b1b;
    text-align: center;
}

.cannabrands-empty {
    padding: 40px 20px;
    text-align: center;
    color: var(--cannabrands-text-muted);
}

8. Implementation Checklist

Phase 1: API Endpoints

  • Create store_api_keys table and migration
  • Implement API key authentication middleware
  • Implement GET /v1/stores/:key/menu endpoint
  • Implement GET /v1/stores/:key/specials endpoint
  • Implement GET /v1/stores/:key/categories endpoint
  • Implement GET /v1/stores/:key/brands endpoint
  • Implement GET /v1/stores/:key/product/:slug endpoint
  • Add rate limiting
  • Test with sample store

Phase 2: WordPress Plugin Core

  • Set up plugin structure
  • Implement Settings page
  • Implement API Client class
  • Implement Cache class
  • Test API connection from WP

Phase 3: Shortcodes

  • Implement [cannabrands_menu]
  • Implement [cannabrands_specials]
  • Implement [cannabrands_carousel]
  • Implement [cannabrands_categories]
  • Implement [cannabrands_product]
  • Create template files
  • Test all shortcodes

Phase 4: Elementor Integration

  • Implement Menu widget
  • Implement Specials widget
  • Implement Carousel widget
  • Implement Categories widget
  • Test in Elementor editor
  • Test live preview

Phase 5: Styling & Polish

  • Complete CSS stylesheet
  • Add responsive breakpoints
  • Add dark mode support (optional)
  • Add carousel JavaScript
  • Browser testing
  • Performance optimization

Phase 6: Documentation & Release

  • Write user documentation
  • Create readme.txt for WordPress.org
  • Create screenshots
  • Set up update mechanism
  • Beta testing with pilot dispensary

Document Version

Version Date Author Changes
1.0 2025-01-15 Claude Initial specification