- 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>
2995 lines
92 KiB
Markdown
2995 lines
92 KiB
Markdown
# 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
|
|
<?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
|
|
<?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
|
|
<?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
|
|
<?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
|
|
<?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
|
|
|
|
```html
|
|
<!-- 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
|
|
<?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
|
|
<?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
|
|
<?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
|
|
<?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
|
|
|
|
```php
|
|
// Cache key format
|
|
"cannabrands_{store_key}_{endpoint}_{md5(serialized_params)}"
|
|
|
|
// Examples:
|
|
"cannabrands_deeply-rooted_menu_a1b2c3d4" // Full menu
|
|
"cannabrands_deeply-rooted_menu_e5f6g7h8" // Menu with category filter
|
|
"cannabrands_deeply-rooted_specials_i9j0k1l2" // Specials
|
|
"cannabrands_deeply-rooted_categories_m3n4o5p6" // Categories (less volatile)
|
|
```
|
|
|
|
### 5.3 Cache TTL Recommendations
|
|
|
|
| Endpoint | TTL | Rationale |
|
|
|----------|-----|-----------|
|
|
| `/menu` | 5 minutes | Products change frequently (price, stock) |
|
|
| `/specials` | 5 minutes | Deals may be time-sensitive |
|
|
| `/categories` | 10 minutes | Category structure changes rarely |
|
|
| `/brands` | 10 minutes | Brand list changes rarely |
|
|
| `/product/:slug` | 5 minutes | Individual product details |
|
|
|
|
### 5.4 Cache Invalidation
|
|
|
|
**Manual Invalidation:**
|
|
- Admin settings page "Clear Cache" button
|
|
- Clears all plugin transients
|
|
|
|
**Automatic Invalidation:**
|
|
- Transients expire based on TTL
|
|
- No webhook-based invalidation (keep plugin simple)
|
|
|
|
**Optional: Cron-based Prewarming:**
|
|
```php
|
|
// Schedule hourly cache refresh
|
|
add_action('cannabrands_cache_prewarm', function() {
|
|
$api = cannabrands_menu()->api;
|
|
|
|
// Prewarm common requests
|
|
$api->get_menu(['limit' => 100]);
|
|
$api->get_specials(['limit' => 50]);
|
|
$api->get_categories();
|
|
});
|
|
|
|
// Schedule the event
|
|
if (!wp_next_scheduled('cannabrands_cache_prewarm')) {
|
|
wp_schedule_event(time(), 'hourly', 'cannabrands_cache_prewarm');
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Decoupling from Intelligence Layer
|
|
|
|
### 6.1 Separation of Concerns
|
|
|
|
The WordPress plugin uses **current state data only**:
|
|
|
|
| Plugin Uses | Plugin Does NOT Use |
|
|
|-------------|---------------------|
|
|
| `products` table (current) | `brand_daily_metrics` |
|
|
| `categories` table | `brand_promo_daily_metrics` |
|
|
| `stores` table | `brand_store_events` |
|
|
| `store_products` (current state) | `store_product_snapshots` (history) |
|
|
| Parsed special fields | Intelligence aggregations |
|
|
|
|
### 6.2 API Path Separation
|
|
|
|
```
|
|
/v1/stores/:store_key/* → WordPress Plugin (store-scoped, current state)
|
|
/v1/brands/:brand_key/* → Cannabrands App (brand-scoped, analytics)
|
|
```
|
|
|
|
### 6.3 No Cross-Contamination
|
|
|
|
The store-facing endpoints:
|
|
- Do NOT query `brand_daily_metrics` or any aggregation tables
|
|
- Do NOT expose brand identifiers or intelligence data
|
|
- Are read-only against `products` + `categories` + current `store_products` state
|
|
- Use simple JOIN queries, not analytical window functions
|
|
|
|
### 6.4 Example Query (Store Menu)
|
|
|
|
```sql
|
|
-- What the /stores/:key/menu endpoint queries:
|
|
SELECT
|
|
p.id,
|
|
p.slug,
|
|
p.name,
|
|
p.brand,
|
|
p.description,
|
|
p.thc_percentage,
|
|
p.cbd_percentage,
|
|
p.strain_type,
|
|
p.weight,
|
|
p.price,
|
|
p.original_price,
|
|
p.in_stock,
|
|
p.special_text,
|
|
p.image_url_full,
|
|
p.dutchie_url,
|
|
p.last_seen_at,
|
|
c.id as category_id,
|
|
c.name as category_name,
|
|
c.slug as category_slug
|
|
FROM products p
|
|
JOIN categories c ON c.id = p.category_id
|
|
WHERE p.store_id = $1
|
|
AND p.in_stock = $2 -- optional filter
|
|
AND c.slug = $3 -- optional filter
|
|
ORDER BY p.name ASC
|
|
LIMIT $4 OFFSET $5;
|
|
```
|
|
|
|
No references to:
|
|
- `canonical_brands`
|
|
- `brand_store_presence`
|
|
- `brand_daily_metrics`
|
|
- `store_product_snapshots`
|
|
|
|
---
|
|
|
|
## 7. Template Examples
|
|
|
|
### 7.1 Product Card Template (`templates/partials/product-card.php`)
|
|
|
|
```php
|
|
<?php
|
|
/**
|
|
* 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`)
|
|
|
|
```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 |
|