# 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
```
### 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 |