# 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: ```sql 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`):** ```json { "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`):** ```json { "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:** ```json { "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:** ```json { "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:** ```json { "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:** ```json { "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 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 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 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 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 '

' . __('Enter your Cannabrands API credentials. Contact support@cannabrands.app if you need API access.', 'cannabrands-menu') . '

'; } public function render_cache_section() { echo '

' . __('Menu data is cached to improve performance. Click "Clear Cache" to fetch fresh data.', 'cannabrands-menu') . '

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

{$description}

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

{$description}

"; } } 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 ""; if ($description) { echo "

{$description}

"; } } public function render_checkbox_field($args) { $field = $args['field']; $checked = $this->get($field) ? 'checked' : ''; $label = isset($args['label']) ? $args['label'] : ''; echo ""; } 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 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 '

' . __('Product slug is required', 'cannabrands-menu') . '

'; } $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 ''; } 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 '
' . 'Cannabrands Menu Error: ' . esc_html($error->get_error_message()) . '
'; } return ''; } } ``` ### 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 ```html [cannabrands_menu] [cannabrands_menu category="flower" sort="thc_desc" limit="50"] [cannabrands_menu category="pre-rolls" brand="Raw Garden" columns="3"] [cannabrands_specials limit="10" show_savings="yes"] [cannabrands_specials type="bogo" layout="banner"] [cannabrands_carousel category="concentrates" limit="8" autoplay="yes" speed="4000"] [cannabrands_carousel specials_only="yes" limit="6" visible="3"] [cannabrands_categories layout="grid" columns="4" show_icons="yes"] [cannabrands_product slug="raw-garden-live-resin-slurm-og-1g"] ``` --- ## 4. Elementor Widgets ### 4.1 Elementor Integration Class ```php 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 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 '
' . esc_html($data->get_error_message()) . '
'; } return; } $products = isset($data['products']) ? $data['products'] : []; if (empty($products) && \Elementor\Plugin::$instance->editor->is_edit_mode()) { echo '
' . __('No products found. Adjust your filters or check API settings.', 'cannabrands-menu') . '
'; 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 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 '
' . esc_html($data->get_error_message()) . '
'; } 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')]; } } ``` ### 4.4 Carousel Widget ```php 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 '
' . esc_html($data->get_error_message()) . '
'; } 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 ```php // 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:** ```php // 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) ```sql -- 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
<?php echo esc_attr($product['name']); ?>

THC: % CBD: %
``` ### 7.2 Base CSS (`assets/css/cannabrands-menu.css`) ```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 |