- Fix column name from s.dutchie_plus_url to s.dutchie_url - Add availability tracking and product freshness APIs - Add crawl script for sequential dispensary processing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
92 KiB
Cannabrands WordPress Menu Plugin - Technical Specification
Overview
This document specifies the WordPress plugin that allows dispensaries to display their product menus, specials, and carousels on their WordPress websites - replacing the need for Dutchie Plus.
Mental Model
┌─────────────────────────────────────────────────────────────────────┐
│ CRAWLER + CANONICAL TABLES │
│ (Single Source of Truth) │
└─────────────────────────────────┬───────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
│ │
▼ ▼
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ BRAND INTELLIGENCE │ │ STORE MENU PLUGIN │
│ (Cannabrands App) │ │ (WordPress/Dispensaries) │
├─────────────────────────────┤ ├─────────────────────────────┤
│ • brand_daily_metrics │ │ • products (current state) │
│ • brand_promo_daily_metrics │ │ • categories │
│ • brand_store_events │ │ • specials (parsed deals) │
│ • v_brand_store_detail │ │ │
├─────────────────────────────┤ ├─────────────────────────────┤
│ Auth: JWT with brand_key │ │ Auth: API key + store_id │
│ Path: /v1/brands/:key/... │ │ Path: /v1/stores/:key/... │
└─────────────────────────────┘ └─────────────────────────────┘
Non-Negotiable Constraints
- DO NOT modify the crawler
- DO NOT break existing API endpoints
- DO NOT couple plugin to intelligence/analytics tables
- Store menu endpoints use current state only (not historical metrics)
- Plugin is read-only - no writes to our database
1. Store-Facing API Endpoints
These endpoints are specifically for the WordPress plugin. They are separate from Brand Intelligence endpoints.
1.1 Authentication
Header: X-Store-API-Key: {api_key}
API keys are issued per-store and stored in a store_api_keys table:
CREATE TABLE store_api_keys (
id SERIAL PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id),
api_key VARCHAR(64) NOT NULL,
name VARCHAR(100), -- "WordPress Plugin", "Mobile App", etc.
is_active BOOLEAN NOT NULL DEFAULT TRUE,
rate_limit INTEGER DEFAULT 1000, -- requests per hour
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
CONSTRAINT uq_store_api_keys_key UNIQUE (api_key)
);
CREATE INDEX idx_store_api_keys_key ON store_api_keys(api_key) WHERE is_active = TRUE;
CREATE INDEX idx_store_api_keys_store ON store_api_keys(store_id);
1.2 GET /v1/stores/:store_key/menu
Returns the full product menu for a store, optionally grouped by category.
Path Parameters:
| Param | Type | Description |
|---|---|---|
store_key |
string | Store slug or ID |
Query Parameters:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
category |
string | No | - | Filter by category slug |
brand |
string | No | - | Filter by brand name |
in_stock |
boolean | No | true |
Filter by stock status |
sort |
string | No | name |
Sort: name, price_asc, price_desc, thc_desc, newest |
limit |
number | No | 500 |
Max products (max 1000) |
offset |
number | No | 0 |
Pagination offset |
group_by_category |
boolean | No | false |
Group products by category |
Response (Flat List - group_by_category=false):
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento",
"city": "Sacramento",
"state": "CA",
"logo_url": "https://...",
"dutchie_url": "https://dutchie.com/dispensary/deeply-rooted"
},
"meta": {
"total": 342,
"limit": 50,
"offset": 0,
"has_more": true,
"generated_at": "2025-01-15T08:00:00.000Z",
"cache_ttl_seconds": 300
},
"products": [
{
"id": 12345,
"slug": "raw-garden-live-resin-slurm-og-1g",
"name": "Live Resin - Slurm OG",
"full_name": "Raw Garden Live Resin - Slurm OG",
"brand": "Raw Garden",
"category": {
"id": 5,
"name": "Concentrates",
"slug": "concentrates",
"parent_slug": null
},
"subcategory": {
"id": 12,
"name": "Live Resin",
"slug": "live-resin",
"parent_slug": "concentrates"
},
"strain_type": "hybrid",
"weight": "1g",
"thc_percent": 82.5,
"cbd_percent": 0.1,
"price": {
"current": 45.00,
"regular": 55.00,
"is_on_special": true,
"discount_percent": 18.2,
"formatted": {
"current": "$45.00",
"regular": "$55.00",
"savings": "$10.00"
}
},
"special": {
"active": true,
"text": "20% off all Raw Garden",
"type": "percent_off",
"ends_at": null
},
"stock": {
"in_stock": true,
"quantity": null
},
"images": {
"thumbnail": "https://images.dutchie.com/.../thumb.jpg",
"medium": "https://images.dutchie.com/.../medium.jpg",
"full": "https://images.dutchie.com/.../full.jpg"
},
"description": "Premium live resin with...",
"terpenes": ["Limonene", "Myrcene", "Caryophyllene"],
"effects": ["Relaxed", "Happy", "Euphoric"],
"dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/raw-garden-live-resin-slurm-og",
"last_updated": "2025-01-15T06:30:00.000Z"
}
]
}
Response (Grouped - group_by_category=true):
{
"store": { ... },
"meta": {
"total": 342,
"categories_count": 8,
"generated_at": "2025-01-15T08:00:00.000Z",
"cache_ttl_seconds": 300
},
"categories": [
{
"id": 1,
"name": "Flower",
"slug": "flower",
"product_count": 85,
"products": [
{ ... },
{ ... }
]
},
{
"id": 2,
"name": "Pre-Rolls",
"slug": "pre-rolls",
"product_count": 42,
"products": [ ... ]
}
]
}
1.3 GET /v1/stores/:store_key/specials
Returns products currently on special/deal.
Query Parameters:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
category |
string | No | - | Filter by category slug |
brand |
string | No | - | Filter by brand name |
special_type |
string | No | - | Filter: percent_off, dollar_off, bogo, bundle |
sort |
string | No | discount_desc |
Sort: discount_desc, price_asc, name |
limit |
number | No | 50 |
Max products |
Response:
{
"store": { ... },
"meta": {
"total_specials": 34,
"generated_at": "2025-01-15T08:00:00.000Z",
"cache_ttl_seconds": 300
},
"specials": [
{
"id": 12345,
"slug": "raw-garden-live-resin-slurm-og-1g",
"name": "Live Resin - Slurm OG",
"brand": "Raw Garden",
"category": {
"name": "Concentrates",
"slug": "concentrates"
},
"strain_type": "hybrid",
"weight": "1g",
"thc_percent": 82.5,
"price": {
"current": 45.00,
"regular": 55.00,
"discount_percent": 18.2,
"formatted": {
"current": "$45.00",
"regular": "$55.00",
"savings": "$10.00"
}
},
"special": {
"text": "20% off all Raw Garden",
"type": "percent_off",
"value": 20,
"badge_text": "20% OFF",
"ends_at": null
},
"images": {
"thumbnail": "https://...",
"medium": "https://..."
},
"in_stock": true,
"dutchie_url": "https://..."
}
],
"summary": {
"by_type": {
"percent_off": 18,
"dollar_off": 5,
"bogo": 8,
"bundle": 2,
"other": 1
},
"by_category": [
{ "name": "Flower", "count": 12 },
{ "name": "Concentrates", "count": 10 },
{ "name": "Edibles", "count": 8 }
],
"avg_discount_percent": 22.5
}
}
1.4 GET /v1/stores/:store_key/categories
Returns the category tree for a store's menu.
Response:
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento"
},
"categories": [
{
"id": 1,
"name": "Flower",
"slug": "flower",
"product_count": 85,
"in_stock_count": 72,
"icon": "flower",
"children": [
{
"id": 10,
"name": "Indoor",
"slug": "indoor",
"product_count": 45,
"in_stock_count": 40
},
{
"id": 11,
"name": "Outdoor",
"slug": "outdoor",
"product_count": 25,
"in_stock_count": 20
}
]
},
{
"id": 2,
"name": "Pre-Rolls",
"slug": "pre-rolls",
"product_count": 42,
"in_stock_count": 38,
"icon": "joint",
"children": []
},
{
"id": 3,
"name": "Vapes",
"slug": "vapes",
"product_count": 65,
"in_stock_count": 58,
"icon": "vape",
"children": [
{
"id": 20,
"name": "Cartridges",
"slug": "cartridges",
"product_count": 40
},
{
"id": 21,
"name": "Disposables",
"slug": "disposables",
"product_count": 25
}
]
}
]
}
1.5 GET /v1/stores/:store_key/brands
Returns all brands available at the store.
Response:
{
"store": { ... },
"brands": [
{
"name": "Raw Garden",
"slug": "raw-garden",
"product_count": 24,
"in_stock_count": 21,
"on_special_count": 3,
"logo_url": null,
"categories": ["Concentrates", "Vapes"]
},
{
"name": "Stiiizy",
"slug": "stiiizy",
"product_count": 18,
"in_stock_count": 15,
"on_special_count": 0,
"logo_url": null,
"categories": ["Vapes", "Flower"]
}
]
}
1.6 GET /v1/stores/:store_key/product/:product_slug
Returns a single product's full details.
Response:
{
"product": {
"id": 12345,
"slug": "raw-garden-live-resin-slurm-og-1g",
"name": "Live Resin - Slurm OG",
"full_name": "Raw Garden Live Resin - Slurm OG",
"brand": "Raw Garden",
"category": {
"id": 5,
"name": "Concentrates",
"slug": "concentrates"
},
"subcategory": {
"id": 12,
"name": "Live Resin",
"slug": "live-resin"
},
"strain_type": "hybrid",
"weight": "1g",
"thc_percent": 82.5,
"cbd_percent": 0.1,
"price": {
"current": 45.00,
"regular": 55.00,
"is_on_special": true,
"discount_percent": 18.2
},
"special": {
"active": true,
"text": "20% off all Raw Garden",
"type": "percent_off"
},
"in_stock": true,
"images": {
"thumbnail": "https://...",
"medium": "https://...",
"full": "https://..."
},
"description": "Premium live resin extracted from fresh frozen cannabis...",
"terpenes": ["Limonene", "Myrcene", "Caryophyllene"],
"effects": ["Relaxed", "Happy", "Euphoric"],
"flavors": ["Citrus", "Earthy", "Pine"],
"lineage": "Unknown lineage",
"dutchie_url": "https://...",
"last_updated": "2025-01-15T06:30:00.000Z"
},
"related_products": [
{ ... },
{ ... }
]
}
2. WordPress Plugin Architecture
2.1 Directory Structure
cannabrands-menu/
├── cannabrands-menu.php # Main plugin file
├── readme.txt # WordPress.org readme
├── uninstall.php # Cleanup on uninstall
│
├── includes/
│ ├── class-cannabrands-plugin.php # Main plugin class
│ ├── class-cannabrands-api-client.php # API communication
│ ├── class-cannabrands-cache.php # Transient caching
│ ├── class-cannabrands-settings.php # Admin settings page
│ ├── class-cannabrands-shortcodes.php # Shortcode handlers
│ ├── class-cannabrands-rest-proxy.php # Optional: local REST proxy
│ │
│ ├── elementor/
│ │ ├── class-cannabrands-elementor.php # Elementor integration
│ │ ├── widgets/
│ │ │ ├── class-widget-menu.php # Menu widget
│ │ │ ├── class-widget-specials.php # Specials widget
│ │ │ ├── class-widget-carousel.php # Product carousel
│ │ │ ├── class-widget-categories.php # Category list/grid
│ │ │ └── class-widget-product-card.php # Single product card
│ │ └── controls/
│ │ └── class-category-control.php # Custom category selector
│ │
│ └── blocks/
│ ├── class-cannabrands-blocks.php # Gutenberg blocks registration
│ ├── menu/
│ │ ├── block.json
│ │ ├── edit.js
│ │ └── render.php
│ ├── specials/
│ │ ├── block.json
│ │ ├── edit.js
│ │ └── render.php
│ └── carousel/
│ ├── block.json
│ ├── edit.js
│ └── render.php
│
├── assets/
│ ├── css/
│ │ ├── cannabrands-menu.css # Base styles
│ │ ├── cannabrands-menu-elementor.css # Elementor-specific
│ │ └── cannabrands-menu-admin.css # Admin styles
│ │
│ ├── js/
│ │ ├── cannabrands-menu.js # Frontend JS (carousel, etc.)
│ │ ├── cannabrands-admin.js # Admin JS
│ │ └── blocks/ # Gutenberg block JS
│ │
│ └── images/
│ └── placeholder.png # Product placeholder
│
├── templates/
│ ├── menu/
│ │ ├── menu-grid.php # Grid layout template
│ │ ├── menu-list.php # List layout template
│ │ └── menu-grouped.php # Category-grouped template
│ │
│ ├── specials/
│ │ ├── specials-grid.php
│ │ └── specials-banner.php
│ │
│ ├── carousel/
│ │ └── carousel.php
│ │
│ ├── partials/
│ │ ├── product-card.php # Reusable product card
│ │ ├── product-card-compact.php # Compact variant
│ │ ├── price-display.php # Price with special handling
│ │ ├── category-pill.php # Category badge
│ │ └── special-badge.php # Deal badge
│ │
│ └── admin/
│ └── settings-page.php
│
└── languages/
└── cannabrands-menu.pot # Translation template
2.2 Main Plugin Class
<?php
/**
* Plugin Name: Cannabrands Menu
* Description: Display your dispensary menu, specials, and product carousels
* Version: 1.0.0
* Author: Cannabrands
* Text Domain: cannabrands-menu
*/
if (!defined('ABSPATH')) exit;
define('CANNABRANDS_MENU_VERSION', '1.0.0');
define('CANNABRANDS_MENU_PATH', plugin_dir_path(__FILE__));
define('CANNABRANDS_MENU_URL', plugin_dir_url(__FILE__));
class Cannabrands_Menu_Plugin {
private static $instance = null;
/** @var Cannabrands_Api_Client */
public $api;
/** @var Cannabrands_Cache */
public $cache;
/** @var Cannabrands_Settings */
public $settings;
public static function instance() {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->load_dependencies();
$this->init_components();
$this->register_hooks();
}
private function load_dependencies() {
require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-api-client.php';
require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-cache.php';
require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-settings.php';
require_once CANNABRANDS_MENU_PATH . 'includes/class-cannabrands-shortcodes.php';
// Elementor (if active)
if (did_action('elementor/loaded')) {
require_once CANNABRANDS_MENU_PATH . 'includes/elementor/class-cannabrands-elementor.php';
}
// Gutenberg blocks
require_once CANNABRANDS_MENU_PATH . 'includes/blocks/class-cannabrands-blocks.php';
}
private function init_components() {
$this->settings = new Cannabrands_Settings();
$this->cache = new Cannabrands_Cache();
$this->api = new Cannabrands_Api_Client($this->settings, $this->cache);
new Cannabrands_Shortcodes($this->api);
if (did_action('elementor/loaded')) {
new Cannabrands_Elementor($this->api);
}
new Cannabrands_Blocks($this->api);
}
private function register_hooks() {
add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
// AJAX handlers for live preview in admin
add_action('wp_ajax_cannabrands_preview_menu', [$this, 'ajax_preview_menu']);
add_action('wp_ajax_cannabrands_clear_cache', [$this, 'ajax_clear_cache']);
}
public function enqueue_frontend_assets() {
wp_enqueue_style(
'cannabrands-menu',
CANNABRANDS_MENU_URL . 'assets/css/cannabrands-menu.css',
[],
CANNABRANDS_MENU_VERSION
);
wp_enqueue_script(
'cannabrands-menu',
CANNABRANDS_MENU_URL . 'assets/js/cannabrands-menu.js',
['jquery'],
CANNABRANDS_MENU_VERSION,
true
);
// Pass config to JS
wp_localize_script('cannabrands-menu', 'cannabrandsMenu', [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('cannabrands_menu'),
'storeSlug' => $this->settings->get('store_slug'),
]);
}
public function enqueue_admin_assets($hook) {
if ($hook !== 'settings_page_cannabrands-menu') {
return;
}
wp_enqueue_style(
'cannabrands-menu-admin',
CANNABRANDS_MENU_URL . 'assets/css/cannabrands-menu-admin.css',
[],
CANNABRANDS_MENU_VERSION
);
wp_enqueue_script(
'cannabrands-menu-admin',
CANNABRANDS_MENU_URL . 'assets/js/cannabrands-admin.js',
['jquery'],
CANNABRANDS_MENU_VERSION,
true
);
}
public function ajax_clear_cache() {
check_ajax_referer('cannabrands_admin', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error('Unauthorized');
}
$this->cache->clear_all();
wp_send_json_success(['message' => 'Cache cleared successfully']);
}
}
// Initialize
function cannabrands_menu() {
return Cannabrands_Menu_Plugin::instance();
}
add_action('plugins_loaded', 'cannabrands_menu');
2.3 API Client Class
<?php
class Cannabrands_Api_Client {
/** @var Cannabrands_Settings */
private $settings;
/** @var Cannabrands_Cache */
private $cache;
public function __construct(Cannabrands_Settings $settings, Cannabrands_Cache $cache) {
$this->settings = $settings;
$this->cache = $cache;
}
/**
* Get the full menu for the configured store
*
* @param array $params {
* @type string $category Category slug filter
* @type string $brand Brand name filter
* @type bool $in_stock Filter by stock status (default: true)
* @type string $sort Sort order: name, price_asc, price_desc, thc_desc, newest
* @type int $limit Max products (default: 500)
* @type int $offset Pagination offset
* @type bool $group_by_category Group products by category
* }
* @return array|WP_Error
*/
public function get_menu(array $params = []) {
$defaults = [
'in_stock' => true,
'sort' => 'name',
'limit' => 500,
'offset' => 0,
'group_by_category' => false,
];
$params = wp_parse_args($params, $defaults);
$cache_key = $this->build_cache_key('menu', $params);
$cached = $this->cache->get($cache_key);
if ($cached !== false) {
return $cached;
}
$response = $this->request('GET', '/menu', $params);
if (!is_wp_error($response)) {
$this->cache->set($cache_key, $response, $this->get_cache_ttl());
}
return $response;
}
/**
* Get products currently on special
*
* @param array $params {
* @type string $category Category slug filter
* @type string $brand Brand name filter
* @type string $special_type Filter: percent_off, dollar_off, bogo, bundle
* @type string $sort Sort: discount_desc, price_asc, name
* @type int $limit Max products (default: 50)
* }
* @return array|WP_Error
*/
public function get_specials(array $params = []) {
$defaults = [
'sort' => 'discount_desc',
'limit' => 50,
];
$params = wp_parse_args($params, $defaults);
$cache_key = $this->build_cache_key('specials', $params);
$cached = $this->cache->get($cache_key);
if ($cached !== false) {
return $cached;
}
$response = $this->request('GET', '/specials', $params);
if (!is_wp_error($response)) {
$this->cache->set($cache_key, $response, $this->get_cache_ttl());
}
return $response;
}
/**
* Get category tree
*
* @return array|WP_Error
*/
public function get_categories() {
$cache_key = $this->build_cache_key('categories', []);
$cached = $this->cache->get($cache_key);
if ($cached !== false) {
return $cached;
}
$response = $this->request('GET', '/categories');
if (!is_wp_error($response)) {
// Categories change less frequently - cache longer
$this->cache->set($cache_key, $response, $this->get_cache_ttl() * 2);
}
return $response;
}
/**
* Get brands list
*
* @return array|WP_Error
*/
public function get_brands() {
$cache_key = $this->build_cache_key('brands', []);
$cached = $this->cache->get($cache_key);
if ($cached !== false) {
return $cached;
}
$response = $this->request('GET', '/brands');
if (!is_wp_error($response)) {
$this->cache->set($cache_key, $response, $this->get_cache_ttl() * 2);
}
return $response;
}
/**
* Get single product details
*
* @param string $product_slug
* @return array|WP_Error
*/
public function get_product(string $product_slug) {
$cache_key = $this->build_cache_key('product', ['slug' => $product_slug]);
$cached = $this->cache->get($cache_key);
if ($cached !== false) {
return $cached;
}
$response = $this->request('GET', '/product/' . sanitize_title($product_slug));
if (!is_wp_error($response)) {
$this->cache->set($cache_key, $response, $this->get_cache_ttl());
}
return $response;
}
/**
* Make API request
*/
private function request(string $method, string $endpoint, array $params = []) {
$base_url = $this->settings->get('api_base_url');
$store_key = $this->settings->get('store_key');
$api_key = $this->settings->get('api_key');
if (empty($base_url) || empty($store_key) || empty($api_key)) {
return new WP_Error(
'cannabrands_not_configured',
__('Cannabrands Menu plugin is not configured. Please enter your API credentials in Settings.', 'cannabrands-menu')
);
}
$url = trailingslashit($base_url) . 'v1/stores/' . $store_key . $endpoint;
if (!empty($params) && $method === 'GET') {
$url = add_query_arg($params, $url);
}
$args = [
'method' => $method,
'timeout' => 30,
'headers' => [
'X-Store-API-Key' => $api_key,
'Accept' => 'application/json',
'User-Agent' => 'Cannabrands-Menu-Plugin/' . CANNABRANDS_MENU_VERSION,
],
];
$response = wp_remote_request($url, $args);
if (is_wp_error($response)) {
return $response;
}
$code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if ($code !== 200) {
$message = isset($data['message']) ? $data['message'] : 'API request failed';
return new WP_Error('cannabrands_api_error', $message, ['status' => $code]);
}
return $data;
}
private function build_cache_key(string $endpoint, array $params): string {
$store_key = $this->settings->get('store_key');
$param_hash = md5(serialize($params));
return "cannabrands_{$store_key}_{$endpoint}_{$param_hash}";
}
private function get_cache_ttl(): int {
return (int) $this->settings->get('cache_ttl', 300); // Default 5 minutes
}
}
2.4 Cache Class
<?php
class Cannabrands_Cache {
const CACHE_GROUP = 'cannabrands_menu';
/**
* Get cached value
*
* @param string $key
* @return mixed|false
*/
public function get(string $key) {
// Try object cache first (if available)
if (wp_using_ext_object_cache()) {
return wp_cache_get($key, self::CACHE_GROUP);
}
// Fall back to transients
return get_transient($key);
}
/**
* Set cached value
*
* @param string $key
* @param mixed $value
* @param int $ttl Seconds
* @return bool
*/
public function set(string $key, $value, int $ttl = 300): bool {
// Try object cache first
if (wp_using_ext_object_cache()) {
return wp_cache_set($key, $value, self::CACHE_GROUP, $ttl);
}
// Fall back to transients
return set_transient($key, $value, $ttl);
}
/**
* Delete cached value
*
* @param string $key
* @return bool
*/
public function delete(string $key): bool {
if (wp_using_ext_object_cache()) {
return wp_cache_delete($key, self::CACHE_GROUP);
}
return delete_transient($key);
}
/**
* Clear all plugin cache
*/
public function clear_all(): void {
global $wpdb;
// Clear transients matching our prefix
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_cannabrands_%'
OR option_name LIKE '_transient_timeout_cannabrands_%'"
);
// If object cache, try to flush our group
if (wp_using_ext_object_cache() && function_exists('wp_cache_flush_group')) {
wp_cache_flush_group(self::CACHE_GROUP);
}
}
/**
* Get or set - fetch from cache or execute callback
*
* @param string $key
* @param callable $callback
* @param int $ttl
* @return mixed
*/
public function remember(string $key, callable $callback, int $ttl = 300) {
$cached = $this->get($key);
if ($cached !== false) {
return $cached;
}
$value = $callback();
if (!is_wp_error($value)) {
$this->set($key, $value, $ttl);
}
return $value;
}
}
2.5 Settings Class
<?php
class Cannabrands_Settings {
const OPTION_NAME = 'cannabrands_menu_settings';
private $settings;
public function __construct() {
$this->settings = get_option(self::OPTION_NAME, $this->get_defaults());
add_action('admin_menu', [$this, 'add_menu_page']);
add_action('admin_init', [$this, 'register_settings']);
}
private function get_defaults(): array {
return [
'api_base_url' => 'https://api.cannabrands.app',
'store_key' => '',
'api_key' => '',
'cache_ttl' => 300,
'default_image' => '',
'enable_dutchie_links' => true,
'price_currency' => 'USD',
'price_position' => 'before', // before or after
];
}
public function get(string $key, $default = null) {
return isset($this->settings[$key]) ? $this->settings[$key] : $default;
}
public function add_menu_page() {
add_options_page(
__('Cannabrands Menu Settings', 'cannabrands-menu'),
__('Cannabrands Menu', 'cannabrands-menu'),
'manage_options',
'cannabrands-menu',
[$this, 'render_settings_page']
);
}
public function register_settings() {
register_setting(self::OPTION_NAME, self::OPTION_NAME, [
'sanitize_callback' => [$this, 'sanitize_settings'],
]);
// API Settings Section
add_settings_section(
'cannabrands_api_section',
__('API Configuration', 'cannabrands-menu'),
[$this, 'render_api_section'],
'cannabrands-menu'
);
add_settings_field(
'api_base_url',
__('API Base URL', 'cannabrands-menu'),
[$this, 'render_text_field'],
'cannabrands-menu',
'cannabrands_api_section',
['field' => 'api_base_url', 'description' => 'Usually https://api.cannabrands.app']
);
add_settings_field(
'store_key',
__('Store ID / Slug', 'cannabrands-menu'),
[$this, 'render_text_field'],
'cannabrands-menu',
'cannabrands_api_section',
['field' => 'store_key', 'description' => 'Your store identifier (e.g., deeply-rooted-sacramento)']
);
add_settings_field(
'api_key',
__('API Key', 'cannabrands-menu'),
[$this, 'render_password_field'],
'cannabrands-menu',
'cannabrands_api_section',
['field' => 'api_key', 'description' => 'Your store API key']
);
// Cache Settings Section
add_settings_section(
'cannabrands_cache_section',
__('Cache Settings', 'cannabrands-menu'),
[$this, 'render_cache_section'],
'cannabrands-menu'
);
add_settings_field(
'cache_ttl',
__('Cache Duration (seconds)', 'cannabrands-menu'),
[$this, 'render_number_field'],
'cannabrands-menu',
'cannabrands_cache_section',
['field' => 'cache_ttl', 'min' => 60, 'max' => 3600, 'description' => 'How long to cache menu data (300 = 5 minutes)']
);
// Display Settings Section
add_settings_section(
'cannabrands_display_section',
__('Display Settings', 'cannabrands-menu'),
null,
'cannabrands-menu'
);
add_settings_field(
'enable_dutchie_links',
__('Enable Dutchie Links', 'cannabrands-menu'),
[$this, 'render_checkbox_field'],
'cannabrands-menu',
'cannabrands_display_section',
['field' => 'enable_dutchie_links', 'label' => 'Link products to Dutchie product pages']
);
}
public function render_settings_page() {
include CANNABRANDS_MENU_PATH . 'templates/admin/settings-page.php';
}
public function render_api_section() {
echo '<p>' . __('Enter your Cannabrands API credentials. Contact support@cannabrands.app if you need API access.', 'cannabrands-menu') . '</p>';
}
public function render_cache_section() {
echo '<p>' . __('Menu data is cached to improve performance. Click "Clear Cache" to fetch fresh data.', 'cannabrands-menu') . '</p>';
echo '<button type="button" class="button" id="cannabrands-clear-cache">' . __('Clear Cache', 'cannabrands-menu') . '</button>';
echo '<span id="cannabrands-cache-status" style="margin-left: 10px;"></span>';
}
public function render_text_field($args) {
$field = $args['field'];
$value = esc_attr($this->get($field, ''));
$description = isset($args['description']) ? $args['description'] : '';
echo "<input type='text' name='" . self::OPTION_NAME . "[{$field}]' value='{$value}' class='regular-text' />";
if ($description) {
echo "<p class='description'>{$description}</p>";
}
}
public function render_password_field($args) {
$field = $args['field'];
$value = esc_attr($this->get($field, ''));
$description = isset($args['description']) ? $args['description'] : '';
echo "<input type='password' name='" . self::OPTION_NAME . "[{$field}]' value='{$value}' class='regular-text' />";
if ($description) {
echo "<p class='description'>{$description}</p>";
}
}
public function render_number_field($args) {
$field = $args['field'];
$value = esc_attr($this->get($field, ''));
$min = isset($args['min']) ? $args['min'] : 0;
$max = isset($args['max']) ? $args['max'] : 9999;
$description = isset($args['description']) ? $args['description'] : '';
echo "<input type='number' name='" . self::OPTION_NAME . "[{$field}]' value='{$value}' min='{$min}' max='{$max}' class='small-text' />";
if ($description) {
echo "<p class='description'>{$description}</p>";
}
}
public function render_checkbox_field($args) {
$field = $args['field'];
$checked = $this->get($field) ? 'checked' : '';
$label = isset($args['label']) ? $args['label'] : '';
echo "<label><input type='checkbox' name='" . self::OPTION_NAME . "[{$field}]' value='1' {$checked} /> {$label}</label>";
}
public function sanitize_settings($input) {
$sanitized = [];
$sanitized['api_base_url'] = esc_url_raw($input['api_base_url'] ?? '');
$sanitized['store_key'] = sanitize_text_field($input['store_key'] ?? '');
$sanitized['api_key'] = sanitize_text_field($input['api_key'] ?? '');
$sanitized['cache_ttl'] = absint($input['cache_ttl'] ?? 300);
$sanitized['enable_dutchie_links'] = isset($input['enable_dutchie_links']) ? 1 : 0;
// Validate cache TTL range
if ($sanitized['cache_ttl'] < 60) $sanitized['cache_ttl'] = 60;
if ($sanitized['cache_ttl'] > 3600) $sanitized['cache_ttl'] = 3600;
return $sanitized;
}
}
3. Shortcodes
3.1 Shortcode Registration
<?php
class Cannabrands_Shortcodes {
/** @var Cannabrands_Api_Client */
private $api;
public function __construct(Cannabrands_Api_Client $api) {
$this->api = $api;
add_shortcode('cannabrands_menu', [$this, 'render_menu']);
add_shortcode('cannabrands_specials', [$this, 'render_specials']);
add_shortcode('cannabrands_carousel', [$this, 'render_carousel']);
add_shortcode('cannabrands_categories', [$this, 'render_categories']);
add_shortcode('cannabrands_product', [$this, 'render_product']);
}
/**
* [cannabrands_menu]
*
* Attributes:
* category - Filter by category slug (e.g., "flower", "vapes")
* brand - Filter by brand name
* sort - Sort order: name, price_asc, price_desc, thc_desc, newest
* limit - Max products to show (default: 100)
* layout - Display: grid, list, grouped (default: grid)
* columns - Grid columns: 2, 3, 4, 5 (default: 4)
* show_price - Show price: yes/no (default: yes)
* show_thc - Show THC%: yes/no (default: yes)
* show_stock - Show stock status: yes/no (default: yes)
* in_stock - Only show in-stock: yes/no (default: yes)
*/
public function render_menu($atts): string {
$atts = shortcode_atts([
'category' => '',
'brand' => '',
'sort' => 'name',
'limit' => 100,
'layout' => 'grid',
'columns' => 4,
'show_price' => 'yes',
'show_thc' => 'yes',
'show_stock' => 'yes',
'in_stock' => 'yes',
], $atts, 'cannabrands_menu');
$params = [
'sort' => sanitize_text_field($atts['sort']),
'limit' => absint($atts['limit']),
'in_stock' => $atts['in_stock'] === 'yes',
'group_by_category' => $atts['layout'] === 'grouped',
];
if (!empty($atts['category'])) {
$params['category'] = sanitize_text_field($atts['category']);
}
if (!empty($atts['brand'])) {
$params['brand'] = sanitize_text_field($atts['brand']);
}
$data = $this->api->get_menu($params);
if (is_wp_error($data)) {
return $this->render_error($data);
}
$template = $atts['layout'] === 'grouped' ? 'menu-grouped' : 'menu-grid';
return $this->load_template("menu/{$template}", [
'data' => $data,
'atts' => $atts,
]);
}
/**
* [cannabrands_specials]
*
* Attributes:
* category - Filter by category slug
* brand - Filter by brand name
* type - Special type: percent_off, dollar_off, bogo, bundle
* limit - Max products (default: 10)
* layout - Display: grid, list, banner (default: grid)
* columns - Grid columns (default: 4)
* show_savings - Show savings amount: yes/no (default: yes)
*/
public function render_specials($atts): string {
$atts = shortcode_atts([
'category' => '',
'brand' => '',
'type' => '',
'limit' => 10,
'layout' => 'grid',
'columns' => 4,
'show_savings' => 'yes',
], $atts, 'cannabrands_specials');
$params = [
'limit' => absint($atts['limit']),
];
if (!empty($atts['category'])) {
$params['category'] = sanitize_text_field($atts['category']);
}
if (!empty($atts['brand'])) {
$params['brand'] = sanitize_text_field($atts['brand']);
}
if (!empty($atts['type'])) {
$params['special_type'] = sanitize_text_field($atts['type']);
}
$data = $this->api->get_specials($params);
if (is_wp_error($data)) {
return $this->render_error($data);
}
$template = $atts['layout'] === 'banner' ? 'specials-banner' : 'specials-grid';
return $this->load_template("specials/{$template}", [
'data' => $data,
'atts' => $atts,
]);
}
/**
* [cannabrands_carousel]
*
* Attributes:
* category - Filter by category slug
* brand - Filter by brand name
* specials_only - Only show specials: yes/no (default: no)
* limit - Max products (default: 12)
* visible - Visible items at once (default: 4)
* autoplay - Enable autoplay: yes/no (default: no)
* speed - Autoplay speed in ms (default: 3000)
* show_arrows - Show nav arrows: yes/no (default: yes)
* show_dots - Show pagination dots: yes/no (default: yes)
*/
public function render_carousel($atts): string {
$atts = shortcode_atts([
'category' => '',
'brand' => '',
'specials_only' => 'no',
'limit' => 12,
'visible' => 4,
'autoplay' => 'no',
'speed' => 3000,
'show_arrows' => 'yes',
'show_dots' => 'yes',
], $atts, 'cannabrands_carousel');
if ($atts['specials_only'] === 'yes') {
$params = ['limit' => absint($atts['limit'])];
if (!empty($atts['category'])) {
$params['category'] = sanitize_text_field($atts['category']);
}
if (!empty($atts['brand'])) {
$params['brand'] = sanitize_text_field($atts['brand']);
}
$data = $this->api->get_specials($params);
$products = isset($data['specials']) ? $data['specials'] : [];
} else {
$params = [
'limit' => absint($atts['limit']),
'in_stock' => true,
];
if (!empty($atts['category'])) {
$params['category'] = sanitize_text_field($atts['category']);
}
if (!empty($atts['brand'])) {
$params['brand'] = sanitize_text_field($atts['brand']);
}
$data = $this->api->get_menu($params);
$products = isset($data['products']) ? $data['products'] : [];
}
if (is_wp_error($data)) {
return $this->render_error($data);
}
return $this->load_template('carousel/carousel', [
'products' => $products,
'atts' => $atts,
]);
}
/**
* [cannabrands_categories]
*
* Attributes:
* layout - Display: grid, list, dropdown (default: grid)
* columns - Grid columns (default: 4)
* show_count - Show product count: yes/no (default: yes)
* show_icons - Show category icons: yes/no (default: yes)
* link_to - Link behavior: page, filter, none (default: filter)
*/
public function render_categories($atts): string {
$atts = shortcode_atts([
'layout' => 'grid',
'columns' => 4,
'show_count' => 'yes',
'show_icons' => 'yes',
'link_to' => 'filter',
], $atts, 'cannabrands_categories');
$data = $this->api->get_categories();
if (is_wp_error($data)) {
return $this->render_error($data);
}
return $this->load_template('categories/categories-grid', [
'data' => $data,
'atts' => $atts,
]);
}
/**
* [cannabrands_product slug="..."]
*
* Attributes:
* slug - Product slug (required)
* layout - Display: card, full, compact (default: card)
*/
public function render_product($atts): string {
$atts = shortcode_atts([
'slug' => '',
'layout' => 'card',
], $atts, 'cannabrands_product');
if (empty($atts['slug'])) {
return '<p class="cannabrands-error">' . __('Product slug is required', 'cannabrands-menu') . '</p>';
}
$data = $this->api->get_product($atts['slug']);
if (is_wp_error($data)) {
return $this->render_error($data);
}
return $this->load_template('partials/product-card', [
'product' => $data['product'],
'layout' => $atts['layout'],
]);
}
private function load_template(string $template, array $vars = []): string {
$template_path = CANNABRANDS_MENU_PATH . 'templates/' . $template . '.php';
// Allow theme override
$theme_template = locate_template('cannabrands-menu/' . $template . '.php');
if ($theme_template) {
$template_path = $theme_template;
}
if (!file_exists($template_path)) {
return '<!-- Template not found: ' . esc_html($template) . ' -->';
}
extract($vars);
ob_start();
include $template_path;
return ob_get_clean();
}
private function render_error(WP_Error $error): string {
if (current_user_can('manage_options')) {
return '<div class="cannabrands-error cannabrands-error-admin">' .
'<strong>Cannabrands Menu Error:</strong> ' .
esc_html($error->get_error_message()) .
'</div>';
}
return '<!-- Cannabrands Menu: ' . esc_html($error->get_error_code()) . ' -->';
}
}
3.2 Shortcode Summary Table
| Shortcode | Purpose | Key Attributes |
|---|---|---|
[cannabrands_menu] |
Full product menu | category, brand, sort, limit, layout, columns |
[cannabrands_specials] |
Products on special | category, brand, type, limit, layout |
[cannabrands_carousel] |
Horizontal product slider | category, specials_only, limit, visible, autoplay |
[cannabrands_categories] |
Category list/grid | layout, columns, show_count, link_to |
[cannabrands_product] |
Single product card | slug, layout |
3.3 Example Shortcode Usage
<!-- Full menu with default settings -->
[cannabrands_menu]
<!-- Flower category only, sorted by THC -->
[cannabrands_menu category="flower" sort="thc_desc" limit="50"]
<!-- Pre-rolls from Raw Garden -->
[cannabrands_menu category="pre-rolls" brand="Raw Garden" columns="3"]
<!-- Today's specials -->
[cannabrands_specials limit="10" show_savings="yes"]
<!-- BOGO deals only -->
[cannabrands_specials type="bogo" layout="banner"]
<!-- Featured carousel with autoplay -->
[cannabrands_carousel category="concentrates" limit="8" autoplay="yes" speed="4000"]
<!-- Specials carousel -->
[cannabrands_carousel specials_only="yes" limit="6" visible="3"]
<!-- Category navigation -->
[cannabrands_categories layout="grid" columns="4" show_icons="yes"]
<!-- Single product embed -->
[cannabrands_product slug="raw-garden-live-resin-slurm-og-1g"]
4. Elementor Widgets
4.1 Elementor Integration Class
<?php
class Cannabrands_Elementor {
/** @var Cannabrands_Api_Client */
private $api;
public function __construct(Cannabrands_Api_Client $api) {
$this->api = $api;
add_action('elementor/widgets/register', [$this, 'register_widgets']);
add_action('elementor/elements/categories_registered', [$this, 'register_category']);
add_action('elementor/editor/after_enqueue_styles', [$this, 'editor_styles']);
}
public function register_category($elements_manager) {
$elements_manager->add_category('cannabrands', [
'title' => __('Cannabrands Menu', 'cannabrands-menu'),
'icon' => 'fa fa-cannabis',
]);
}
public function register_widgets($widgets_manager) {
require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-menu.php';
require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-specials.php';
require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-carousel.php';
require_once CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/class-widget-categories.php';
$widgets_manager->register(new Cannabrands_Widget_Menu($this->api));
$widgets_manager->register(new Cannabrands_Widget_Specials($this->api));
$widgets_manager->register(new Cannabrands_Widget_Carousel($this->api));
$widgets_manager->register(new Cannabrands_Widget_Categories($this->api));
}
public function editor_styles() {
wp_enqueue_style(
'cannabrands-elementor-editor',
CANNABRANDS_MENU_URL . 'assets/css/cannabrands-menu-elementor.css',
[],
CANNABRANDS_MENU_VERSION
);
}
}
4.2 Menu Widget
<?php
use Elementor\Widget_Base;
use Elementor\Controls_Manager;
use Elementor\Group_Control_Typography;
class Cannabrands_Widget_Menu extends Widget_Base {
private $api;
public function __construct($data = [], $args = null, $api = null) {
parent::__construct($data, $args);
$this->api = $api ?? cannabrands_menu()->api;
}
public function get_name() {
return 'cannabrands_menu';
}
public function get_title() {
return __('Product Menu', 'cannabrands-menu');
}
public function get_icon() {
return 'eicon-posts-grid';
}
public function get_categories() {
return ['cannabrands'];
}
public function get_keywords() {
return ['menu', 'products', 'cannabis', 'dispensary'];
}
protected function register_controls() {
// ========== CONTENT TAB ==========
$this->start_controls_section('section_content', [
'label' => __('Content', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
// Category filter
$this->add_control('category', [
'label' => __('Category', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => '',
'options' => $this->get_category_options(),
'description' => __('Filter products by category', 'cannabrands-menu'),
]);
// Brand filter
$this->add_control('brand', [
'label' => __('Brand', 'cannabrands-menu'),
'type' => Controls_Manager::TEXT,
'default' => '',
'placeholder' => __('e.g., Raw Garden', 'cannabrands-menu'),
'description' => __('Filter products by brand name', 'cannabrands-menu'),
]);
// Sort order
$this->add_control('sort', [
'label' => __('Sort By', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => 'name',
'options' => [
'name' => __('Name (A-Z)', 'cannabrands-menu'),
'price_asc' => __('Price (Low to High)', 'cannabrands-menu'),
'price_desc' => __('Price (High to Low)', 'cannabrands-menu'),
'thc_desc' => __('THC (High to Low)', 'cannabrands-menu'),
'newest' => __('Newest First', 'cannabrands-menu'),
],
]);
// Limit
$this->add_control('limit', [
'label' => __('Products to Show', 'cannabrands-menu'),
'type' => Controls_Manager::NUMBER,
'default' => 50,
'min' => 1,
'max' => 500,
]);
// In stock only
$this->add_control('in_stock_only', [
'label' => __('In Stock Only', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->end_controls_section();
// ========== LAYOUT SECTION ==========
$this->start_controls_section('section_layout', [
'label' => __('Layout', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
// Layout type
$this->add_control('layout', [
'label' => __('Layout', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => 'grid',
'options' => [
'grid' => __('Grid', 'cannabrands-menu'),
'list' => __('List', 'cannabrands-menu'),
'grouped' => __('Grouped by Category', 'cannabrands-menu'),
],
]);
// Columns
$this->add_responsive_control('columns', [
'label' => __('Columns', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => '4',
'tablet_default' => '3',
'mobile_default' => '2',
'options' => [
'1' => '1',
'2' => '2',
'3' => '3',
'4' => '4',
'5' => '5',
'6' => '6',
],
'condition' => [
'layout' => ['grid', 'grouped'],
],
'selectors' => [
'{{WRAPPER}} .cannabrands-menu-grid' => 'grid-template-columns: repeat({{VALUE}}, 1fr);',
],
]);
// Gap
$this->add_responsive_control('gap', [
'label' => __('Gap', 'cannabrands-menu'),
'type' => Controls_Manager::SLIDER,
'size_units' => ['px', 'em'],
'default' => ['size' => 20, 'unit' => 'px'],
'selectors' => [
'{{WRAPPER}} .cannabrands-menu-grid' => 'gap: {{SIZE}}{{UNIT}};',
],
]);
$this->end_controls_section();
// ========== CARD CONTENT SECTION ==========
$this->start_controls_section('section_card_content', [
'label' => __('Card Content', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
$this->add_control('show_image', [
'label' => __('Show Image', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_brand', [
'label' => __('Show Brand', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_category', [
'label' => __('Show Category', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_price', [
'label' => __('Show Price', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_thc', [
'label' => __('Show THC %', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_strain_type', [
'label' => __('Show Strain Type', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_stock_badge', [
'label' => __('Show Stock Badge', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_special_badge', [
'label' => __('Show Special Badge', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->end_controls_section();
// ========== STYLE TAB ==========
$this->start_controls_section('section_card_style', [
'label' => __('Card Style', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_STYLE,
]);
$this->add_control('card_background', [
'label' => __('Background Color', 'cannabrands-menu'),
'type' => Controls_Manager::COLOR,
'selectors' => [
'{{WRAPPER}} .cannabrands-product-card' => 'background-color: {{VALUE}};',
],
]);
$this->add_control('card_border_radius', [
'label' => __('Border Radius', 'cannabrands-menu'),
'type' => Controls_Manager::DIMENSIONS,
'size_units' => ['px', '%'],
'selectors' => [
'{{WRAPPER}} .cannabrands-product-card' => 'border-radius: {{TOP}}{{UNIT}} {{RIGHT}}{{UNIT}} {{BOTTOM}}{{UNIT}} {{LEFT}}{{UNIT}};',
],
]);
$this->add_group_control(\Elementor\Group_Control_Box_Shadow::get_type(), [
'name' => 'card_shadow',
'selector' => '{{WRAPPER}} .cannabrands-product-card',
]);
$this->end_controls_section();
// Typography section
$this->start_controls_section('section_typography', [
'label' => __('Typography', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_STYLE,
]);
$this->add_group_control(Group_Control_Typography::get_type(), [
'name' => 'product_name_typography',
'label' => __('Product Name', 'cannabrands-menu'),
'selector' => '{{WRAPPER}} .cannabrands-product-name',
]);
$this->add_group_control(Group_Control_Typography::get_type(), [
'name' => 'price_typography',
'label' => __('Price', 'cannabrands-menu'),
'selector' => '{{WRAPPER}} .cannabrands-product-price',
]);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$params = [
'sort' => $settings['sort'],
'limit' => $settings['limit'],
'in_stock' => $settings['in_stock_only'] === 'yes',
'group_by_category' => $settings['layout'] === 'grouped',
];
if (!empty($settings['category'])) {
$params['category'] = $settings['category'];
}
if (!empty($settings['brand'])) {
$params['brand'] = $settings['brand'];
}
$data = $this->api->get_menu($params);
if (is_wp_error($data)) {
if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
echo '<div class="cannabrands-error">' . esc_html($data->get_error_message()) . '</div>';
}
return;
}
$products = isset($data['products']) ? $data['products'] : [];
if (empty($products) && \Elementor\Plugin::$instance->editor->is_edit_mode()) {
echo '<div class="cannabrands-empty">' . __('No products found. Adjust your filters or check API settings.', 'cannabrands-menu') . '</div>';
return;
}
include CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/views/menu-view.php';
}
private function get_category_options(): array {
$options = ['' => __('All Categories', 'cannabrands-menu')];
$data = $this->api->get_categories();
if (!is_wp_error($data) && isset($data['categories'])) {
foreach ($data['categories'] as $cat) {
$options[$cat['slug']] = $cat['name'];
// Add children with indentation
if (!empty($cat['children'])) {
foreach ($cat['children'] as $child) {
$options[$child['slug']] = '— ' . $child['name'];
}
}
}
}
return $options;
}
}
4.3 Specials Widget
<?php
use Elementor\Widget_Base;
use Elementor\Controls_Manager;
class Cannabrands_Widget_Specials extends Widget_Base {
private $api;
public function __construct($data = [], $args = null, $api = null) {
parent::__construct($data, $args);
$this->api = $api ?? cannabrands_menu()->api;
}
public function get_name() {
return 'cannabrands_specials';
}
public function get_title() {
return __('Specials & Deals', 'cannabrands-menu');
}
public function get_icon() {
return 'eicon-price-table';
}
public function get_categories() {
return ['cannabrands'];
}
protected function register_controls() {
$this->start_controls_section('section_content', [
'label' => __('Content', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
// Heading
$this->add_control('heading', [
'label' => __('Heading', 'cannabrands-menu'),
'type' => Controls_Manager::TEXT,
'default' => __("Today's Specials", 'cannabrands-menu'),
]);
// Category filter
$this->add_control('category', [
'label' => __('Category', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => '',
'options' => $this->get_category_options(),
]);
// Brand filter
$this->add_control('brand', [
'label' => __('Brand', 'cannabrands-menu'),
'type' => Controls_Manager::TEXT,
'default' => '',
]);
// Special type filter
$this->add_control('special_type', [
'label' => __('Deal Type', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => '',
'options' => [
'' => __('All Types', 'cannabrands-menu'),
'percent_off' => __('Percent Off', 'cannabrands-menu'),
'dollar_off' => __('Dollar Off', 'cannabrands-menu'),
'bogo' => __('BOGO', 'cannabrands-menu'),
'bundle' => __('Bundle Deals', 'cannabrands-menu'),
],
]);
// Limit
$this->add_control('limit', [
'label' => __('Products to Show', 'cannabrands-menu'),
'type' => Controls_Manager::NUMBER,
'default' => 8,
'min' => 1,
'max' => 50,
]);
$this->end_controls_section();
// Layout section
$this->start_controls_section('section_layout', [
'label' => __('Layout', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
$this->add_control('layout', [
'label' => __('Layout', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => 'grid',
'options' => [
'grid' => __('Grid', 'cannabrands-menu'),
'list' => __('List', 'cannabrands-menu'),
'banner' => __('Banner Style', 'cannabrands-menu'),
],
]);
$this->add_responsive_control('columns', [
'label' => __('Columns', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => '4',
'options' => ['1' => '1', '2' => '2', '3' => '3', '4' => '4', '5' => '5'],
'condition' => ['layout' => 'grid'],
'selectors' => [
'{{WRAPPER}} .cannabrands-specials-grid' => 'grid-template-columns: repeat({{VALUE}}, 1fr);',
],
]);
$this->end_controls_section();
// Display options
$this->start_controls_section('section_display', [
'label' => __('Display Options', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
$this->add_control('show_original_price', [
'label' => __('Show Original Price (Strikethrough)', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_savings', [
'label' => __('Show Savings Amount', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_deal_badge', [
'label' => __('Show Deal Badge', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_deal_text', [
'label' => __('Show Deal Description', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->end_controls_section();
// Badge styling
$this->start_controls_section('section_badge_style', [
'label' => __('Deal Badge', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_STYLE,
]);
$this->add_control('badge_background', [
'label' => __('Badge Background', 'cannabrands-menu'),
'type' => Controls_Manager::COLOR,
'default' => '#ef4444',
'selectors' => [
'{{WRAPPER}} .cannabrands-special-badge' => 'background-color: {{VALUE}};',
],
]);
$this->add_control('badge_color', [
'label' => __('Badge Text Color', 'cannabrands-menu'),
'type' => Controls_Manager::COLOR,
'default' => '#ffffff',
'selectors' => [
'{{WRAPPER}} .cannabrands-special-badge' => 'color: {{VALUE}};',
],
]);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
$params = [
'limit' => $settings['limit'],
];
if (!empty($settings['category'])) {
$params['category'] = $settings['category'];
}
if (!empty($settings['brand'])) {
$params['brand'] = $settings['brand'];
}
if (!empty($settings['special_type'])) {
$params['special_type'] = $settings['special_type'];
}
$data = $this->api->get_specials($params);
if (is_wp_error($data)) {
if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
echo '<div class="cannabrands-error">' . esc_html($data->get_error_message()) . '</div>';
}
return;
}
$specials = isset($data['specials']) ? $data['specials'] : [];
include CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/views/specials-view.php';
}
private function get_category_options(): array {
// Same implementation as Menu widget
return ['' => __('All Categories', 'cannabrands-menu')];
}
}
4.4 Carousel Widget
<?php
use Elementor\Widget_Base;
use Elementor\Controls_Manager;
class Cannabrands_Widget_Carousel extends Widget_Base {
private $api;
public function __construct($data = [], $args = null, $api = null) {
parent::__construct($data, $args);
$this->api = $api ?? cannabrands_menu()->api;
}
public function get_name() {
return 'cannabrands_carousel';
}
public function get_title() {
return __('Product Carousel', 'cannabrands-menu');
}
public function get_icon() {
return 'eicon-slider-push';
}
public function get_categories() {
return ['cannabrands'];
}
public function get_script_depends() {
return ['cannabrands-carousel'];
}
protected function register_controls() {
// Content section
$this->start_controls_section('section_content', [
'label' => __('Content', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
$this->add_control('heading', [
'label' => __('Heading', 'cannabrands-menu'),
'type' => Controls_Manager::TEXT,
'default' => __('Featured Products', 'cannabrands-menu'),
]);
$this->add_control('source', [
'label' => __('Product Source', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => 'all',
'options' => [
'all' => __('All Products', 'cannabrands-menu'),
'specials' => __('Specials Only', 'cannabrands-menu'),
'category' => __('Specific Category', 'cannabrands-menu'),
'brand' => __('Specific Brand', 'cannabrands-menu'),
],
]);
$this->add_control('category', [
'label' => __('Category', 'cannabrands-menu'),
'type' => Controls_Manager::SELECT,
'default' => '',
'options' => $this->get_category_options(),
'condition' => ['source' => ['category', 'all']],
]);
$this->add_control('brand', [
'label' => __('Brand', 'cannabrands-menu'),
'type' => Controls_Manager::TEXT,
'default' => '',
'condition' => ['source' => ['brand', 'all']],
]);
$this->add_control('limit', [
'label' => __('Products to Load', 'cannabrands-menu'),
'type' => Controls_Manager::NUMBER,
'default' => 12,
'min' => 4,
'max' => 24,
]);
$this->end_controls_section();
// Carousel settings
$this->start_controls_section('section_carousel', [
'label' => __('Carousel Settings', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
$this->add_responsive_control('slides_to_show', [
'label' => __('Slides to Show', 'cannabrands-menu'),
'type' => Controls_Manager::NUMBER,
'default' => 4,
'tablet_default' => 3,
'mobile_default' => 1,
'min' => 1,
'max' => 6,
]);
$this->add_control('autoplay', [
'label' => __('Autoplay', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => '',
]);
$this->add_control('autoplay_speed', [
'label' => __('Autoplay Speed (ms)', 'cannabrands-menu'),
'type' => Controls_Manager::NUMBER,
'default' => 3000,
'min' => 1000,
'max' => 10000,
'step' => 500,
'condition' => ['autoplay' => 'yes'],
]);
$this->add_control('infinite', [
'label' => __('Infinite Loop', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_arrows', [
'label' => __('Show Arrows', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_dots', [
'label' => __('Show Dots', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->end_controls_section();
// Card content options
$this->start_controls_section('section_card', [
'label' => __('Card Content', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_CONTENT,
]);
$this->add_control('show_brand', [
'label' => __('Show Brand', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_price', [
'label' => __('Show Price', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->add_control('show_thc', [
'label' => __('Show THC', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => '',
]);
$this->add_control('show_special_badge', [
'label' => __('Show Special Badge', 'cannabrands-menu'),
'type' => Controls_Manager::SWITCHER,
'default' => 'yes',
]);
$this->end_controls_section();
// Arrow styling
$this->start_controls_section('section_arrows_style', [
'label' => __('Navigation Arrows', 'cannabrands-menu'),
'tab' => Controls_Manager::TAB_STYLE,
'condition' => ['show_arrows' => 'yes'],
]);
$this->add_control('arrow_color', [
'label' => __('Arrow Color', 'cannabrands-menu'),
'type' => Controls_Manager::COLOR,
'selectors' => [
'{{WRAPPER}} .cannabrands-carousel-arrow' => 'color: {{VALUE}};',
],
]);
$this->add_control('arrow_background', [
'label' => __('Arrow Background', 'cannabrands-menu'),
'type' => Controls_Manager::COLOR,
'selectors' => [
'{{WRAPPER}} .cannabrands-carousel-arrow' => 'background-color: {{VALUE}};',
],
]);
$this->end_controls_section();
}
protected function render() {
$settings = $this->get_settings_for_display();
// Determine which API to call based on source
if ($settings['source'] === 'specials') {
$params = ['limit' => $settings['limit']];
if (!empty($settings['category'])) {
$params['category'] = $settings['category'];
}
$data = $this->api->get_specials($params);
$products = isset($data['specials']) ? $data['specials'] : [];
} else {
$params = [
'limit' => $settings['limit'],
'in_stock' => true,
];
if (!empty($settings['category'])) {
$params['category'] = $settings['category'];
}
if (!empty($settings['brand'])) {
$params['brand'] = $settings['brand'];
}
$data = $this->api->get_menu($params);
$products = isset($data['products']) ? $data['products'] : [];
}
if (is_wp_error($data)) {
if (\Elementor\Plugin::$instance->editor->is_edit_mode()) {
echo '<div class="cannabrands-error">' . esc_html($data->get_error_message()) . '</div>';
}
return;
}
// Build carousel config for JS
$carousel_config = [
'slidesToShow' => (int) $settings['slides_to_show'],
'slidesToShowTablet' => (int) ($settings['slides_to_show_tablet'] ?? 3),
'slidesToShowMobile' => (int) ($settings['slides_to_show_mobile'] ?? 1),
'autoplay' => $settings['autoplay'] === 'yes',
'autoplaySpeed' => (int) $settings['autoplay_speed'],
'infinite' => $settings['infinite'] === 'yes',
'arrows' => $settings['show_arrows'] === 'yes',
'dots' => $settings['show_dots'] === 'yes',
];
include CANNABRANDS_MENU_PATH . 'includes/elementor/widgets/views/carousel-view.php';
}
private function get_category_options(): array {
return ['' => __('All Categories', 'cannabrands-menu')];
}
}
4.5 Elementor Widget Summary
| Widget | Controls | Data Source | Renders |
|---|---|---|---|
| Product Menu | Category, Brand, Sort, Limit, Layout (grid/list/grouped), Columns, Card content toggles, Typography, Colors | GET /menu |
Product grid/list with filtering |
| Specials & Deals | Category, Brand, Deal Type, Limit, Layout, Show savings/badges/deal text, Badge styling | GET /specials |
Special products with deal badges |
| Product Carousel | Source (all/specials/category/brand), Slides to show, Autoplay, Speed, Arrows, Dots, Card content | GET /menu or GET /specials |
Horizontal slider |
| Category List | Layout (grid/list/dropdown), Columns, Show count/icons, Link behavior | GET /categories |
Category navigation |
5. Caching Strategy
5.1 Cache Layers
┌─────────────────────────────────────────────────────────────────┐
│ REQUEST FLOW │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ LAYER 1: Object Cache (Redis/Memcached if available) │
│ TTL: 5 minutes │
│ Key: cannabrands_{store}_{endpoint}_{params_hash} │
└─────────────────────────────┬───────────────────────────────────┘
│ MISS
▼
┌─────────────────────────────────────────────────────────────────┐
│ LAYER 2: WordPress Transients (Database) │
│ TTL: 5 minutes │
│ Fallback when no object cache │
└─────────────────────────────┬───────────────────────────────────┘
│ MISS
▼
┌─────────────────────────────────────────────────────────────────┐
│ LAYER 3: API Request │
│ Result cached in Layer 1 or 2 │
└─────────────────────────────────────────────────────────────────┘
5.2 Cache Key Strategy
// Cache key format
"cannabrands_{store_key}_{endpoint}_{md5(serialized_params)}"
// Examples:
"cannabrands_deeply-rooted_menu_a1b2c3d4" // Full menu
"cannabrands_deeply-rooted_menu_e5f6g7h8" // Menu with category filter
"cannabrands_deeply-rooted_specials_i9j0k1l2" // Specials
"cannabrands_deeply-rooted_categories_m3n4o5p6" // Categories (less volatile)
5.3 Cache TTL Recommendations
| Endpoint | TTL | Rationale |
|---|---|---|
/menu |
5 minutes | Products change frequently (price, stock) |
/specials |
5 minutes | Deals may be time-sensitive |
/categories |
10 minutes | Category structure changes rarely |
/brands |
10 minutes | Brand list changes rarely |
/product/:slug |
5 minutes | Individual product details |
5.4 Cache Invalidation
Manual Invalidation:
- Admin settings page "Clear Cache" button
- Clears all plugin transients
Automatic Invalidation:
- Transients expire based on TTL
- No webhook-based invalidation (keep plugin simple)
Optional: Cron-based Prewarming:
// Schedule hourly cache refresh
add_action('cannabrands_cache_prewarm', function() {
$api = cannabrands_menu()->api;
// Prewarm common requests
$api->get_menu(['limit' => 100]);
$api->get_specials(['limit' => 50]);
$api->get_categories();
});
// Schedule the event
if (!wp_next_scheduled('cannabrands_cache_prewarm')) {
wp_schedule_event(time(), 'hourly', 'cannabrands_cache_prewarm');
}
6. Decoupling from Intelligence Layer
6.1 Separation of Concerns
The WordPress plugin uses current state data only:
| Plugin Uses | Plugin Does NOT Use |
|---|---|
products table (current) |
brand_daily_metrics |
categories table |
brand_promo_daily_metrics |
stores table |
brand_store_events |
store_products (current state) |
store_product_snapshots (history) |
| Parsed special fields | Intelligence aggregations |
6.2 API Path Separation
/v1/stores/:store_key/* → WordPress Plugin (store-scoped, current state)
/v1/brands/:brand_key/* → Cannabrands App (brand-scoped, analytics)
6.3 No Cross-Contamination
The store-facing endpoints:
- Do NOT query
brand_daily_metricsor any aggregation tables - Do NOT expose brand identifiers or intelligence data
- Are read-only against
products+categories+ currentstore_productsstate - Use simple JOIN queries, not analytical window functions
6.4 Example Query (Store Menu)
-- What the /stores/:key/menu endpoint queries:
SELECT
p.id,
p.slug,
p.name,
p.brand,
p.description,
p.thc_percentage,
p.cbd_percentage,
p.strain_type,
p.weight,
p.price,
p.original_price,
p.in_stock,
p.special_text,
p.image_url_full,
p.dutchie_url,
p.last_seen_at,
c.id as category_id,
c.name as category_name,
c.slug as category_slug
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.store_id = $1
AND p.in_stock = $2 -- optional filter
AND c.slug = $3 -- optional filter
ORDER BY p.name ASC
LIMIT $4 OFFSET $5;
No references to:
canonical_brandsbrand_store_presencebrand_daily_metricsstore_product_snapshots
7. Template Examples
7.1 Product Card Template (templates/partials/product-card.php)
<?php
/**
* Product card template
*
* @var array $product The product data
* @var array $settings Widget/shortcode settings
*/
$show_image = $settings['show_image'] ?? true;
$show_brand = $settings['show_brand'] ?? true;
$show_price = $settings['show_price'] ?? true;
$show_thc = $settings['show_thc'] ?? true;
$show_special_badge = $settings['show_special_badge'] ?? true;
$image_url = $product['images']['medium'] ?? $product['images']['thumbnail'] ?? '';
$has_special = !empty($product['special']['active']);
$link_url = $product['dutchie_url'] ?? '#';
?>
<div class="cannabrands-product-card" data-product-id="<?php echo esc_attr($product['id']); ?>">
<?php if ($show_image): ?>
<div class="cannabrands-product-image">
<a href="<?php echo esc_url($link_url); ?>" target="_blank" rel="noopener">
<?php if ($image_url): ?>
<img src="<?php echo esc_url($image_url); ?>"
alt="<?php echo esc_attr($product['name']); ?>"
loading="lazy" />
<?php else: ?>
<div class="cannabrands-product-placeholder">
<span><?php esc_html_e('No Image', 'cannabrands-menu'); ?></span>
</div>
<?php endif; ?>
</a>
<?php if ($show_special_badge && $has_special): ?>
<span class="cannabrands-special-badge">
<?php echo esc_html($product['special']['badge_text'] ?? 'SALE'); ?>
</span>
<?php endif; ?>
<?php if (!empty($product['strain_type'])): ?>
<span class="cannabrands-strain-badge cannabrands-strain-<?php echo esc_attr($product['strain_type']); ?>">
<?php echo esc_html(ucfirst($product['strain_type'])); ?>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="cannabrands-product-info">
<?php if ($show_brand && !empty($product['brand'])): ?>
<div class="cannabrands-product-brand">
<?php echo esc_html($product['brand']); ?>
</div>
<?php endif; ?>
<h3 class="cannabrands-product-name">
<a href="<?php echo esc_url($link_url); ?>" target="_blank" rel="noopener">
<?php echo esc_html($product['name']); ?>
</a>
</h3>
<?php if (!empty($product['weight'])): ?>
<div class="cannabrands-product-weight">
<?php echo esc_html($product['weight']); ?>
</div>
<?php endif; ?>
<?php if ($show_thc && !empty($product['thc_percent'])): ?>
<div class="cannabrands-product-cannabinoids">
<span class="cannabrands-thc">THC: <?php echo esc_html($product['thc_percent']); ?>%</span>
<?php if (!empty($product['cbd_percent'])): ?>
<span class="cannabrands-cbd">CBD: <?php echo esc_html($product['cbd_percent']); ?>%</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($show_price): ?>
<div class="cannabrands-product-price">
<?php if ($has_special && !empty($product['price']['regular'])): ?>
<span class="cannabrands-price-regular">
<?php echo esc_html($product['price']['formatted']['regular']); ?>
</span>
<span class="cannabrands-price-current cannabrands-price-sale">
<?php echo esc_html($product['price']['formatted']['current']); ?>
</span>
<?php else: ?>
<span class="cannabrands-price-current">
<?php echo esc_html($product['price']['formatted']['current']); ?>
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!$product['stock']['in_stock']): ?>
<div class="cannabrands-product-stock cannabrands-out-of-stock">
<?php esc_html_e('Out of Stock', 'cannabrands-menu'); ?>
</div>
<?php endif; ?>
</div>
</div>
7.2 Base CSS (assets/css/cannabrands-menu.css)
/* ============================================
CANNABRANDS MENU - BASE STYLES
============================================ */
/* CSS Custom Properties for easy theming */
:root {
--cannabrands-primary: #10b981;
--cannabrands-primary-dark: #059669;
--cannabrands-sale: #ef4444;
--cannabrands-indica: #6366f1;
--cannabrands-sativa: #f59e0b;
--cannabrands-hybrid: #10b981;
--cannabrands-text: #1f2937;
--cannabrands-text-muted: #6b7280;
--cannabrands-border: #e5e7eb;
--cannabrands-background: #ffffff;
--cannabrands-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--cannabrands-card-radius: 8px;
--cannabrands-gap: 20px;
}
/* Grid Layout */
.cannabrands-menu-grid,
.cannabrands-specials-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--cannabrands-gap);
}
@media (max-width: 1024px) {
.cannabrands-menu-grid,
.cannabrands-specials-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.cannabrands-menu-grid,
.cannabrands-specials-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.cannabrands-menu-grid,
.cannabrands-specials-grid {
grid-template-columns: 1fr;
}
}
/* Product Card */
.cannabrands-product-card {
background: var(--cannabrands-background);
border: 1px solid var(--cannabrands-border);
border-radius: var(--cannabrands-card-radius);
overflow: hidden;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.cannabrands-product-card:hover {
box-shadow: var(--cannabrands-card-shadow);
transform: translateY(-2px);
}
/* Product Image */
.cannabrands-product-image {
position: relative;
aspect-ratio: 1;
background: #f9fafb;
}
.cannabrands-product-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.cannabrands-product-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--cannabrands-text-muted);
font-size: 14px;
}
/* Badges */
.cannabrands-special-badge {
position: absolute;
top: 8px;
left: 8px;
background: var(--cannabrands-sale);
color: white;
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 4px;
text-transform: uppercase;
}
.cannabrands-strain-badge {
position: absolute;
top: 8px;
right: 8px;
font-size: 10px;
font-weight: 600;
padding: 3px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.cannabrands-strain-indica { background: var(--cannabrands-indica); color: white; }
.cannabrands-strain-sativa { background: var(--cannabrands-sativa); color: white; }
.cannabrands-strain-hybrid { background: var(--cannabrands-hybrid); color: white; }
/* Product Info */
.cannabrands-product-info {
padding: 12px;
}
.cannabrands-product-brand {
font-size: 12px;
color: var(--cannabrands-text-muted);
margin-bottom: 4px;
}
.cannabrands-product-name {
font-size: 14px;
font-weight: 600;
color: var(--cannabrands-text);
margin: 0 0 4px 0;
line-height: 1.3;
}
.cannabrands-product-name a {
color: inherit;
text-decoration: none;
}
.cannabrands-product-name a:hover {
color: var(--cannabrands-primary);
}
.cannabrands-product-weight {
font-size: 12px;
color: var(--cannabrands-text-muted);
margin-bottom: 8px;
}
.cannabrands-product-cannabinoids {
font-size: 12px;
color: var(--cannabrands-text-muted);
margin-bottom: 8px;
}
.cannabrands-thc {
font-weight: 500;
color: var(--cannabrands-primary-dark);
}
.cannabrands-cbd {
margin-left: 8px;
}
/* Price */
.cannabrands-product-price {
font-size: 16px;
font-weight: 700;
}
.cannabrands-price-current {
color: var(--cannabrands-text);
}
.cannabrands-price-sale {
color: var(--cannabrands-sale);
}
.cannabrands-price-regular {
text-decoration: line-through;
color: var(--cannabrands-text-muted);
font-weight: 400;
font-size: 13px;
margin-right: 6px;
}
/* Stock Status */
.cannabrands-out-of-stock {
font-size: 11px;
color: var(--cannabrands-sale);
font-weight: 500;
margin-top: 6px;
}
/* Carousel */
.cannabrands-carousel {
position: relative;
overflow: hidden;
}
.cannabrands-carousel-track {
display: flex;
transition: transform 0.3s ease;
}
.cannabrands-carousel-slide {
flex: 0 0 25%;
padding: 0 10px;
box-sizing: border-box;
}
.cannabrands-carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
background: var(--cannabrands-background);
border: 1px solid var(--cannabrands-border);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: background 0.2s;
}
.cannabrands-carousel-arrow:hover {
background: #f3f4f6;
}
.cannabrands-carousel-prev { left: 0; }
.cannabrands-carousel-next { right: 0; }
.cannabrands-carousel-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 16px;
}
.cannabrands-carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--cannabrands-border);
cursor: pointer;
}
.cannabrands-carousel-dot.active {
background: var(--cannabrands-primary);
}
/* Error States */
.cannabrands-error {
padding: 20px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: var(--cannabrands-card-radius);
color: #991b1b;
text-align: center;
}
.cannabrands-empty {
padding: 40px 20px;
text-align: center;
color: var(--cannabrands-text-muted);
}
8. Implementation Checklist
Phase 1: API Endpoints
- Create
store_api_keystable and migration - Implement API key authentication middleware
- Implement
GET /v1/stores/:key/menuendpoint - Implement
GET /v1/stores/:key/specialsendpoint - Implement
GET /v1/stores/:key/categoriesendpoint - Implement
GET /v1/stores/:key/brandsendpoint - Implement
GET /v1/stores/:key/product/:slugendpoint - 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 |