Browse Source

Extension Upload

Benjamin Harris 2 tuần trước cách đây
mục cha
commit
336dea90df

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+.claude/

+ 16 - 16
CLAUDE.md

@@ -54,16 +54,16 @@ Each product in the OpenCart admin must show a toggle/checkbox to:
 ```
 upload/
 ├── admin/
-│   ├── controller/extension/module/
+│   ├── controller/extension/module/reverb/
 │   │   └── reverb.php              # Admin settings + manual sync trigger
-│   ├── language/en-gb/extension/module/
+│   ├── language/en-gb/extension/module/reverb/
 │   │   └── reverb.php              # All user-facing strings
-│   ├── model/extension/module/
+│   ├── model/extension/module/reverb/
 │   │   └── reverb.php              # DB interactions (token storage, product map)
-│   └── view/template/extension/module/
+│   └── view/template/extension/module/reverb/
 │       └── reverb.twig             # Admin settings form
 ├── catalog/
-│   └── controller/extension/module/
+│   └── controller/extension/module/reverb/
 │       └── reverb.php              # Webhook endpoint for Reverb callbacks
 └── system/
     └── library/reverb/
@@ -124,17 +124,17 @@ Rate limit: ~100 requests/min on the standard plan — batch operations where po
 
 Key endpoints:
 
-| Endpoint                    | Method | Purpose                            |
-|-----------------------------|--------|------------------------------------|
-| `/my/listings`              | GET    | Fetch the seller's existing listings |
-| `/listings`                 | POST   | Create a new listing               |
-| `/listings/{id}`            | PUT    | Update an existing listing         |
-| `/listings/{id}/end`        | PUT    | End/delist a listing               |
-| `/listings/{id}/photos`     | POST   | Upload a photo to a listing        |
-| `/my/orders`                | GET    | Fetch seller orders                |
-| `/my/orders/{order_number}` | GET    | Fetch a single order               |
-| `/webhooks`                 | POST   | Register a webhook endpoint        |
-| `/categories/flat`          | GET    | Fetch the full Reverb category tree |
+| Endpoint | Method | Purpose |
+| --- | --- | --- |
+| `/my/listings` | GET | List all existing seller listings |
+| `/listings` | POST | Create a new listing |
+| `/listings/{id}` | PUT | Update an existing listing |
+| `/listings/{id}/end` | PUT | End/delist a listing |
+| `/listings/{id}/photos` | POST | Upload a photo to a listing |
+| `/my/orders` | GET | Fetch seller orders |
+| `/my/orders/{order_number}` | GET | Fetch a single order |
+| `/webhooks` | POST | Register a webhook endpoint |
+| `/categories/flat` | GET | Get the full Reverb category tree |
 
 ## Sync Flow
 

+ 150 - 1
README.md

@@ -1 +1,150 @@
-# Reverb Opencart Extension
+# Reverb OpenCart Integration
+
+An OpenCart 3.x extension that synchronises your product catalogue with your [Reverb.com](https://reverb.com/au) marketplace — keeping stock, pricing, product details, images, and orders in sync automatically.
+
+---
+
+## Features
+
+- **Bidirectional sync** — push products from OpenCart to Reverb and pull updates back
+- **Order import** — Reverb orders are automatically created as OpenCart orders
+- **Selective sync** — choose which categories are eligible, with a per-product on/off toggle
+- **Image sync** — product images are uploaded to Reverb listings automatically
+- **Price & stock sync** — changes on either platform propagate to the other
+- **Category mapping** — map each OpenCart category to the correct Reverb category
+- **Per-product condition** — set the Reverb condition (Mint, Excellent, Good, etc.) per product
+- **Webhook support** — real-time updates from Reverb via webhook endpoint
+- **Cron fallback** — scheduled polling when webhooks are not available
+- **Sync log** — full activity log visible in the admin panel
+
+---
+
+## Requirements
+
+| Requirement | Version |
+| --- | --- |
+| OpenCart | 3.x (tested on 3.0.3.x) |
+| PHP | 7.4 or higher (8.1+ recommended) |
+| cURL | Enabled |
+| Reverb Account | Seller account with API access |
+
+---
+
+## File Structure
+
+```text
+upload/
+├── admin/
+│   ├── controller/extension/module/reverb.php
+│   ├── language/en-gb/extension/module/reverb.php
+│   ├── model/extension/module/reverb.php
+│   └── view/template/extension/module/
+│       ├── reverb.twig             (settings page)
+│       └── reverb_product.twig    (per-product Reverb tab)
+├── catalog/
+│   └── controller/extension/module/reverb.php
+└── system/
+    └── library/reverb/
+        ├── ReverbApi.php
+        ├── ProductMapper.php
+        └── OrderMapper.php
+reverb.ocmod.xml
+```
+
+---
+
+## Installation
+
+1. Copy the contents of the `upload/` folder into your OpenCart root directory.
+2. In your OpenCart admin, go to **Extensions > Installer** and upload `reverb.ocmod.xml`.
+3. Go to **Extensions > Modifications** and click **Refresh**.
+4. Go to **Extensions > Extensions**, select **Modules**, find **Reverb Integration**, click **Install**, then **Edit**.
+
+---
+
+## Configuration
+
+### 1. API Token
+
+1. Log in to [reverb.com/au](https://reverb.com/au) and go to **Account Settings > API Keys**.
+2. Generate a personal access token.
+3. Paste it into the **API Token** field in the Reverb module settings and save.
+
+### 2. Sync Direction
+
+| Option | Behaviour |
+| --- | --- |
+| One-way (OpenCart → Reverb) | Products are pushed to Reverb only; changes on Reverb are ignored |
+| Both ways | Changes on either platform sync to the other; Reverb orders are imported |
+
+### 3. Shipping Rates
+
+Set a flat AU domestic rate and an optional international rate. These are applied to all Reverb listings. Set to `0` for free shipping.
+
+### 4. Category Mapping
+
+Select which OpenCart categories are eligible for sync in the **Settings** tab. Then switch to the **Category Mapping** tab and map each selected category to the corresponding Reverb category.
+
+### 5. Per-Product Toggle
+
+On each product's edit page a **Reverb** tab appears. Enable **List on Reverb**, choose the item condition, and optionally override the Reverb category for that product. Once synced, the Reverb listing ID and a direct link appear here.
+
+---
+
+## Sync Behaviour
+
+| Field | OpenCart → Reverb | Reverb → OpenCart |
+| --- | :---: | :---: |
+| Name / Title | ✓ | ✓ |
+| Description | ✓ | ✓ |
+| Price | ✓ | ✓ |
+| Stock / Quantity | ✓ | ✓ |
+| Images | ✓ | — |
+| Orders | — | ✓ |
+
+---
+
+## Webhooks
+
+Register a webhook in your Reverb seller account pointing to:
+
+```text
+https://your-store.com/index.php?route=extension/module/reverb/webhook
+```
+
+Supported events: `listing/update`, `order/create`.
+
+To verify webhook signatures, set a secret in Reverb and store it in `oc_setting` as `module_reverb_webhook_secret`.
+
+---
+
+## Cron (Polling Fallback)
+
+Add this URL to your server's cron scheduler (recommended: every 15–30 minutes):
+
+```text
+https://your-store.com/index.php?route=extension/module/reverb/cron&cron_token=YOUR_TOKEN
+```
+
+Set `module_reverb_cron_token` in `oc_setting` to match.
+
+---
+
+## Database Tables
+
+| Table | Purpose |
+| --- | --- |
+| `oc_reverb_product_map` | Links OC products to Reverb listing IDs; stores per-product sync settings |
+| `oc_reverb_sync_log` | Records every push/pull operation with status and error detail |
+
+---
+
+## Development
+
+See [CLAUDE.md](CLAUDE.md) for full technical architecture, API endpoint reference, field mapping details, and developer guidelines.
+
+---
+
+## License
+
+MIT

+ 107 - 0
reverb.ocmod.xml

@@ -0,0 +1,107 @@
+<?xml version="1.0" encoding="utf-8"?>
+<modification>
+    <name>Reverb Integration</name>
+    <code>reverb</code>
+    <version>1.0.0</version>
+    <author>Reverb OpenCart</author>
+    <link>https://reverb.com/au/page/integrations</link>
+
+    <!--
+        ========================================================================
+        FILE 1: admin/controller/catalog/product.php
+        Inject Reverb data loading before the view is rendered,
+        and Reverb data saving after addProduct / editProduct calls.
+        ========================================================================
+    -->
+    <file path="admin/controller/catalog/product.php">
+
+        <!--
+            Load per-product Reverb data into $data[] so the Twig template
+            can render the Reverb tab. Targets the final setOutput() call in
+            the shared getForm() method, which is used by both add() and edit().
+        -->
+        <operation>
+            <search><![CDATA[$this->response->setOutput($this->load->view('catalog/product', $data));]]></search>
+            <add position="before"><![CDATA[
+                // Reverb Integration: load per-product Reverb data
+                $this->load->model('extension/module/reverb');
+                $reverb_product_id = isset($this->request->get['product_id']) ? (int)$this->request->get['product_id'] : 0;
+                $reverb_row = $this->model_extension_module_reverb->getProductMap($reverb_product_id);
+                $data['reverb_sync_enabled']         = $reverb_row ? (int)$reverb_row['sync_enabled']         : 0;
+                $data['reverb_condition_uuid']        = $reverb_row ? $reverb_row['condition_uuid']            : '';
+                $data['reverb_category_uuid']         = $reverb_row ? $reverb_row['reverb_category_uuid']      : '';
+                $data['reverb_listing_id']            = $reverb_row ? $reverb_row['reverb_listing_id']         : '';
+                $data['reverb_conditions']            = $this->model_extension_module_reverb->getListingConditions();
+                $data['reverb_oc_category_mappings']  = $this->model_extension_module_reverb->getCategoryMappings();
+            ]]></add>
+        </operation>
+
+        <!--
+            Save Reverb data after editProduct() is called.
+        -->
+        <operation>
+            <search><![CDATA[$this->model_catalog_product->editProduct($this->request->get['product_id'], $this->request->post);]]></search>
+            <add position="after"><![CDATA[
+                // Reverb Integration: save per-product Reverb data on edit
+                if (isset($this->request->post['reverb_sync_enabled']) || isset($this->request->post['reverb_condition_uuid'])) {
+                    $this->load->model('extension/module/reverb');
+                    $this->model_extension_module_reverb->saveProductMap((int)$this->request->get['product_id'], [
+                        'sync_enabled'         => isset($this->request->post['reverb_sync_enabled']) ? 1 : 0,
+                        'condition_uuid'       => $this->request->post['reverb_condition_uuid']  ?? '',
+                        'reverb_category_uuid' => $this->request->post['reverb_category_uuid']  ?? '',
+                    ]);
+                }
+            ]]></add>
+        </operation>
+
+        <!--
+            Save Reverb data after addProduct() is called.
+        -->
+        <operation>
+            <search><![CDATA[$product_id = $this->model_catalog_product->addProduct($this->request->post);]]></search>
+            <add position="after"><![CDATA[
+                // Reverb Integration: save per-product Reverb data on add
+                if (isset($this->request->post['reverb_sync_enabled']) || isset($this->request->post['reverb_condition_uuid'])) {
+                    $this->load->model('extension/module/reverb');
+                    $this->model_extension_module_reverb->saveProductMap((int)$product_id, [
+                        'sync_enabled'         => isset($this->request->post['reverb_sync_enabled']) ? 1 : 0,
+                        'condition_uuid'       => $this->request->post['reverb_condition_uuid']  ?? '',
+                        'reverb_category_uuid' => $this->request->post['reverb_category_uuid']  ?? '',
+                    ]);
+                }
+            ]]></add>
+        </operation>
+
+    </file>
+
+    <!--
+        ========================================================================
+        FILE 2: admin/view/template/catalog/product.twig
+        Add a "Reverb" tab to the product edit page.
+        ========================================================================
+    -->
+    <file path="admin/view/template/catalog/product.twig">
+
+        <!-- Add tab navigation item after the Design tab -->
+        <operation>
+            <search><![CDATA[<li><a href="#tab-design" data-toggle="tab">{{ tab_design }}</a></li>]]></search>
+            <add position="after"><![CDATA[
+                <li><a href="#tab-reverb" data-toggle="tab">Reverb</a></li>
+            ]]></add>
+        </operation>
+
+        <!--
+            Inject the Reverb tab pane. We include the dedicated template which
+            has access to all $data variables (conditions, category mappings, etc.)
+            loaded above by the PHP patch.
+        -->
+        <operation>
+            <search><![CDATA[<div class="tab-pane" id="tab-design">]]></search>
+            <add position="before"><![CDATA[
+                {% include 'extension/module/reverb_product.twig' %}
+            ]]></add>
+        </operation>
+
+    </file>
+
+</modification>

+ 268 - 0
upload/admin/controller/extension/module/reverb.php

@@ -0,0 +1,268 @@
+<?php
+class ControllerExtensionModuleReverb extends Controller {
+
+    private $error = [];
+
+    // -------------------------------------------------------------------------
+    // Main settings page
+    // -------------------------------------------------------------------------
+
+    public function index() {
+        $this->load->language('extension/module/reverb');
+        $this->load->model('extension/module/reverb');
+        $this->load->model('setting/setting');
+        $this->load->model('catalog/category');
+
+        $this->document->setTitle($this->language->get('heading_title'));
+
+        if ($this->request->server['REQUEST_METHOD'] === 'POST' && $this->validate()) {
+            $this->model_setting_setting->editSetting('module_reverb', $this->request->post);
+            // Save category mappings separately (they come as a sub-array)
+            if (isset($this->request->post['module_reverb_category_mappings'])) {
+                $this->model_extension_module_reverb->saveCategoryMappings(
+                    $this->request->post['module_reverb_category_mappings']
+                );
+            }
+            $this->session->data['success'] = $this->language->get('text_success');
+            $this->response->redirect($this->url->link('extension/module/reverb', 'user_token=' . $this->session->data['user_token'], true));
+        }
+
+        $data = $this->buildBreadcrumbs();
+
+        // Pull saved settings into $data
+        $fields = [
+            'module_reverb_api_token',
+            'module_reverb_status',
+            'module_reverb_sync_direction',
+            'module_reverb_sync_categories',
+            'module_reverb_shipping_domestic',
+            'module_reverb_shipping_international',
+        ];
+        foreach ($fields as $key) {
+            $data[$key] = $this->request->post[$key] ?? $this->config->get($key);
+        }
+
+        // Defaults
+        $data['module_reverb_sync_direction']      = $data['module_reverb_sync_direction'] ?? 'push';
+        $data['module_reverb_sync_categories']     = $data['module_reverb_sync_categories'] ?? [];
+        $data['module_reverb_shipping_domestic']   = $data['module_reverb_shipping_domestic'] ?? '0.00';
+        $data['module_reverb_shipping_international'] = $data['module_reverb_shipping_international'] ?? '0.00';
+
+        // All OC categories for the multi-select
+        $data['categories'] = $this->getCategoryTree();
+
+        // Category mappings for the mapping tab
+        $data['category_mappings']    = $this->model_extension_module_reverb->getCategoryMappings();
+        $data['reverb_categories']    = $this->model_extension_module_reverb->getReverbCategories();
+        $data['module_reverb_category_mappings'] = $data['category_mappings'];
+
+        // Sync log
+        $data['sync_log'] = $this->model_extension_module_reverb->getSyncLog(200);
+
+        // Alerts
+        $data['error_warning'] = $this->error['warning'] ?? '';
+        $data['success']       = $this->session->data['success'] ?? '';
+        unset($this->session->data['success']);
+
+        $data['action']     = $this->url->link('extension/module/reverb', 'user_token=' . $this->session->data['user_token'], true);
+        $data['cancel']     = $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module', true);
+        $data['sync_url']   = $this->url->link('extension/module/reverb/sync', 'user_token=' . $this->session->data['user_token'], true);
+        $data['categories_url'] = $this->url->link('extension/module/reverb/reverbCategories', 'user_token=' . $this->session->data['user_token'], true);
+
+        $data['header']      = $this->load->controller('common/header');
+        $data['column_left'] = $this->load->controller('common/column_left');
+        $data['footer']      = $this->load->controller('common/footer');
+
+        $this->response->setOutput($this->load->view('extension/module/reverb', $data));
+    }
+
+    // -------------------------------------------------------------------------
+    // Install / Uninstall
+    // -------------------------------------------------------------------------
+
+    public function install() {
+        $this->load->model('extension/module/reverb');
+        $this->model_extension_module_reverb->install();
+
+        // Register events for order pulling (admin side)
+        $this->load->model('setting/event');
+        $this->model_setting_event->addEvent(
+            'reverb',
+            'admin/model/catalog/product/editProduct/after',
+            'extension/module/reverb/eventProductSave'
+        );
+        $this->model_setting_event->addEvent(
+            'reverb',
+            'admin/model/catalog/product/addProduct/after',
+            'extension/module/reverb/eventProductAddSave'
+        );
+    }
+
+    public function uninstall() {
+        $this->load->model('extension/module/reverb');
+        $this->model_extension_module_reverb->uninstall();
+
+        $this->load->model('setting/event');
+        $this->model_setting_event->deleteEventByCode('reverb');
+    }
+
+    // -------------------------------------------------------------------------
+    // Event handlers (called by OC event system on product save)
+    // -------------------------------------------------------------------------
+
+    public function eventProductSave(&$route, &$args, &$output) {
+        $product_id = (int)$args[0];
+        $this->saveProductReverb($product_id);
+    }
+
+    public function eventProductAddSave(&$route, &$args, &$output) {
+        // $output holds the new product_id for addProduct
+        $product_id = (int)$output;
+        if ($product_id) {
+            $this->saveProductReverb($product_id);
+        }
+    }
+
+    private function saveProductReverb($product_id) {
+        if (!isset($this->request->post['reverb_sync_enabled'])) {
+            return;
+        }
+        $this->load->model('extension/module/reverb');
+        $this->model_extension_module_reverb->saveProductMap($product_id, [
+            'sync_enabled'         => (int)(bool)$this->request->post['reverb_sync_enabled'],
+            'condition_uuid'       => $this->request->post['reverb_condition_uuid'] ?? '',
+            'reverb_category_uuid' => $this->request->post['reverb_category_uuid'] ?? '',
+        ]);
+    }
+
+    // -------------------------------------------------------------------------
+    // Manual sync (AJAX)
+    // -------------------------------------------------------------------------
+
+    public function sync() {
+        $this->load->language('extension/module/reverb');
+        $this->load->model('extension/module/reverb');
+
+        $json = ['success' => false];
+
+        if (!$this->user->hasPermission('modify', 'extension/module/reverb')) {
+            $json['error'] = $this->language->get('error_permission');
+            $this->response->addHeader('Content-Type: application/json');
+            $this->response->setOutput(json_encode($json));
+            return;
+        }
+
+        $settings = $this->buildSettings();
+
+        if (empty($settings['api_token'])) {
+            $json['error'] = $this->language->get('error_api_token');
+            $this->response->addHeader('Content-Type: application/json');
+            $this->response->setOutput(json_encode($json));
+            return;
+        }
+
+        $allowed_categories = $this->config->get('module_reverb_sync_categories') ?? [];
+        $products           = $this->model_extension_module_reverb->getSyncEnabledProducts((array)$allowed_categories);
+
+        $pushed = 0;
+        $errors = 0;
+
+        foreach ($products as $product) {
+            try {
+                $this->model_extension_module_reverb->syncProductToReverb($product, $product, $settings);
+                $this->model_extension_module_reverb->log($product['product_id'], 'push', 'success', 'Synced: ' . $product['name']);
+                $pushed++;
+            } catch (Exception $e) {
+                $this->model_extension_module_reverb->log($product['product_id'], 'push', 'error', $e->getMessage());
+                $errors++;
+            }
+        }
+
+        $json['success'] = true;
+        $json['message'] = sprintf($this->language->get('text_sync_complete'), $pushed, $errors);
+
+        $this->response->addHeader('Content-Type: application/json');
+        $this->response->setOutput(json_encode($json));
+    }
+
+    // -------------------------------------------------------------------------
+    // Reverb categories (AJAX — for category mapping dropdowns)
+    // -------------------------------------------------------------------------
+
+    public function reverbCategories() {
+        $this->load->model('extension/module/reverb');
+
+        $categories = $this->model_extension_module_reverb->getReverbCategories();
+
+        $this->response->addHeader('Content-Type: application/json');
+        $this->response->setOutput(json_encode(['categories' => $categories]));
+    }
+
+    // -------------------------------------------------------------------------
+    // Per-product tab (loaded inline via OCMOD template include)
+    // The data is passed through the OCMOD PHP patch, not via a separate request.
+    // -------------------------------------------------------------------------
+
+    // -------------------------------------------------------------------------
+    // Helpers
+    // -------------------------------------------------------------------------
+
+    private function validate() {
+        if (!$this->user->hasPermission('modify', 'extension/module/reverb')) {
+            $this->error['warning'] = $this->language->get('error_permission');
+        }
+
+        $token = $this->request->post['module_reverb_api_token'] ?? '';
+        if (empty(trim($token))) {
+            $this->error['warning'] = $this->language->get('error_api_token');
+        }
+
+        return empty($this->error);
+    }
+
+    private function buildBreadcrumbs() {
+        return [
+            'heading_title' => $this->language->get('heading_title'),
+            'breadcrumbs'   => [
+                [
+                    'text' => $this->language->get('text_home'),
+                    'href' => $this->url->link('common/dashboard', 'user_token=' . $this->session->data['user_token'], true),
+                ],
+                [
+                    'text' => $this->language->get('text_extension'),
+                    'href' => $this->url->link('marketplace/extension', 'user_token=' . $this->session->data['user_token'] . '&type=module', true),
+                ],
+                [
+                    'text' => $this->language->get('heading_title'),
+                    'href' => $this->url->link('extension/module/reverb', 'user_token=' . $this->session->data['user_token'], true),
+                ],
+            ],
+        ];
+    }
+
+    private function buildSettings() {
+        return [
+            'api_token'              => $this->config->get('module_reverb_api_token'),
+            'sync_direction'         => $this->config->get('module_reverb_sync_direction') ?? 'push',
+            'shipping_domestic'      => $this->config->get('module_reverb_shipping_domestic') ?? '0',
+            'shipping_international' => $this->config->get('module_reverb_shipping_international') ?? '0',
+            'currency'               => $this->config->get('config_currency') ?? 'AUD',
+            'store_url'              => $this->config->get('config_url') ?? '',
+        ];
+    }
+
+    private function getCategoryTree($parent_id = 0, $indent = '') {
+        $this->load->model('catalog/category');
+        $categories = [];
+        $results = $this->model_catalog_category->getCategories(['parent_id' => $parent_id]);
+        foreach ($results as $cat) {
+            $categories[] = [
+                'category_id' => $cat['category_id'],
+                'name'        => $indent . $cat['name'],
+            ];
+            $children = $this->getCategoryTree($cat['category_id'], $indent . '&nbsp;&nbsp;&nbsp;');
+            $categories = array_merge($categories, $children);
+        }
+        return $categories;
+    }
+}

+ 68 - 0
upload/admin/language/en-gb/extension/module/reverb.php

@@ -0,0 +1,68 @@
+<?php
+// Heading
+$_['heading_title']              = 'Reverb Integration';
+
+// Breadcrumbs
+$_['text_extension']             = 'Extensions';
+$_['text_module']                = 'Modules';
+
+// Tabs
+$_['tab_settings']               = 'Settings';
+$_['tab_categories']             = 'Category Mapping';
+$_['tab_log']                    = 'Sync Log';
+
+// Settings tab
+$_['text_api_settings']          = 'Reverb API';
+$_['entry_api_token']            = 'API Token';
+$_['help_api_token']             = 'Your personal Reverb API token. Generate one at reverb.com › Account Settings › API Keys.';
+$_['entry_status']               = 'Module Status';
+$_['entry_sync_direction']       = 'Sync Direction';
+$_['text_sync_push']             = 'One-way (OpenCart → Reverb)';
+$_['text_sync_both']             = 'Both ways (OpenCart ↔ Reverb)';
+$_['text_shipping_settings']     = 'Shipping';
+$_['entry_shipping_domestic']    = 'AU Domestic Rate (AUD)';
+$_['help_shipping_domestic']     = 'Flat shipping rate charged to Australian buyers. Leave 0 for free.';
+$_['entry_shipping_international'] = 'International Rate (AUD)';
+$_['help_shipping_international']  = 'Flat rate for international buyers. Leave 0 to disable international shipping.';
+$_['text_sync_settings']         = 'Categories Eligible for Sync';
+$_['help_sync_categories']       = 'Only products in these categories will be eligible for Reverb sync.';
+$_['text_manual_sync']           = 'Manual Sync';
+$_['button_sync_now']            = 'Sync All Eligible Products Now';
+
+// Category mapping tab
+$_['text_category_mapping_help'] = 'Map each OpenCart category to a Reverb category. Products in unmapped categories will be skipped during sync.';
+$_['column_oc_category']         = 'OpenCart Category';
+$_['column_reverb_category']     = 'Reverb Category';
+$_['text_no_categories']         = 'No categories selected. Choose categories in the Settings tab first.';
+
+// Sync log tab
+$_['column_date']                = 'Date';
+$_['column_product']             = 'Product';
+$_['column_direction']           = 'Direction';
+$_['column_status']              = 'Status';
+$_['column_message']             = 'Message';
+$_['text_push']                  = 'OC → Reverb';
+$_['text_pull']                  = 'Reverb → OC';
+$_['text_success']               = 'Success';
+$_['text_error']                 = 'Error';
+$_['text_no_log']                = 'No sync activity yet.';
+
+// Per-product Reverb tab (injected via OCMOD)
+$_['tab_reverb']                 = 'Reverb';
+$_['text_reverb_product']        = 'Reverb Listing';
+$_['entry_reverb_sync_enabled']  = 'List on Reverb';
+$_['help_reverb_sync_enabled']   = 'Enable to include this product in Reverb syncs. The product must also be in an allowed category.';
+$_['entry_reverb_condition']     = 'Condition';
+$_['help_reverb_condition']      = 'Required by Reverb. Choose the condition that best describes this item.';
+$_['entry_reverb_category']      = 'Reverb Category';
+$_['help_reverb_category']       = 'Override the category mapping for this specific product. Leave blank to use the category mapping.';
+$_['text_reverb_listing_id']     = 'Reverb Listing';
+$_['text_view_on_reverb']        = 'View on Reverb';
+$_['text_not_synced']            = 'Not yet synced';
+
+// Success / error messages
+$_['text_success']               = 'Settings saved successfully.';
+$_['text_sync_complete']         = 'Sync complete. %d product(s) pushed, %d error(s).';
+$_['error_permission']           = 'Warning: You do not have permission to modify this module.';
+$_['error_api_token']            = 'API Token is required.';
+$_['error_api_token_invalid']    = 'Could not connect to Reverb with this token. Please check it is correct.';

+ 308 - 0
upload/admin/model/extension/module/reverb.php

@@ -0,0 +1,308 @@
+<?php
+class ModelExtensionModuleReverb extends Model {
+
+    // -------------------------------------------------------------------------
+    // Install / Uninstall
+    // -------------------------------------------------------------------------
+
+    public function install() {
+        $this->db->query("
+            CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_product_map` (
+                `product_id`           INT(11) NOT NULL,
+                `reverb_listing_id`    VARCHAR(64) NOT NULL DEFAULT '',
+                `sync_enabled`         TINYINT(1) NOT NULL DEFAULT 0,
+                `condition_uuid`       VARCHAR(64) NOT NULL DEFAULT '',
+                `reverb_category_uuid` VARCHAR(64) NOT NULL DEFAULT '',
+                `last_synced_at`       DATETIME NULL DEFAULT NULL,
+                PRIMARY KEY (`product_id`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+        ");
+
+        $this->db->query("
+            CREATE TABLE IF NOT EXISTS `" . DB_PREFIX . "reverb_sync_log` (
+                `log_id`     INT(11) NOT NULL AUTO_INCREMENT,
+                `product_id` INT(11) NOT NULL DEFAULT 0,
+                `direction`  ENUM('push','pull') NOT NULL DEFAULT 'push',
+                `status`     ENUM('success','error') NOT NULL DEFAULT 'success',
+                `message`    TEXT NOT NULL,
+                `created_at` DATETIME NOT NULL,
+                PRIMARY KEY (`log_id`),
+                KEY `product_id` (`product_id`),
+                KEY `created_at` (`created_at`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_general_ci;
+        ");
+    }
+
+    public function uninstall() {
+        $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_product_map`");
+        $this->db->query("DROP TABLE IF EXISTS `" . DB_PREFIX . "reverb_sync_log`");
+    }
+
+    // -------------------------------------------------------------------------
+    // Product map CRUD
+    // -------------------------------------------------------------------------
+
+    public function getProductMap($product_id) {
+        $query = $this->db->query("
+            SELECT * FROM `" . DB_PREFIX . "reverb_product_map`
+            WHERE `product_id` = '" . (int)$product_id . "'
+        ");
+        return $query->num_rows ? $query->row : null;
+    }
+
+    public function saveProductMap($product_id, array $data) {
+        $existing = $this->getProductMap($product_id);
+
+        $sync_enabled         = isset($data['sync_enabled'])         ? (int)(bool)$data['sync_enabled']                         : 0;
+        $condition_uuid       = isset($data['condition_uuid'])       ? $this->db->escape($data['condition_uuid'])                : '';
+        $reverb_category_uuid = isset($data['reverb_category_uuid']) ? $this->db->escape($data['reverb_category_uuid'])          : '';
+        $reverb_listing_id    = isset($data['reverb_listing_id'])    ? $this->db->escape($data['reverb_listing_id'])             : '';
+        $last_synced_at       = isset($data['last_synced_at'])       ? "'" . $this->db->escape($data['last_synced_at']) . "'"    : 'NULL';
+
+        if ($existing) {
+            $this->db->query("
+                UPDATE `" . DB_PREFIX . "reverb_product_map`
+                SET `sync_enabled`         = $sync_enabled,
+                    `condition_uuid`       = '$condition_uuid',
+                    `reverb_category_uuid` = '$reverb_category_uuid'"
+                . (!empty($reverb_listing_id) ? ", `reverb_listing_id` = '$reverb_listing_id'" : '')
+                . (isset($data['last_synced_at']) ? ", `last_synced_at` = $last_synced_at" : '')
+                . " WHERE `product_id` = '" . (int)$product_id . "'"
+            );
+        } else {
+            $this->db->query("
+                INSERT INTO `" . DB_PREFIX . "reverb_product_map`
+                (`product_id`, `sync_enabled`, `condition_uuid`, `reverb_category_uuid`, `reverb_listing_id`, `last_synced_at`)
+                VALUES (
+                    '" . (int)$product_id . "',
+                    $sync_enabled,
+                    '$condition_uuid',
+                    '$reverb_category_uuid',
+                    '$reverb_listing_id',
+                    $last_synced_at
+                )
+            ");
+        }
+    }
+
+    public function updateListingId($product_id, $listing_id) {
+        $this->db->query("
+            UPDATE `" . DB_PREFIX . "reverb_product_map`
+            SET `reverb_listing_id` = '" . $this->db->escape($listing_id) . "',
+                `last_synced_at`    = NOW()
+            WHERE `product_id` = '" . (int)$product_id . "'
+        ");
+    }
+
+    /**
+     * Return all products eligible for sync (sync_enabled = 1, in allowed categories).
+     *
+     * @param array $allowed_category_ids  List of OC category IDs.
+     * @return array
+     */
+    public function getSyncEnabledProducts(array $allowed_category_ids) {
+        if (empty($allowed_category_ids)) {
+            return [];
+        }
+
+        $ids = implode(',', array_map('intval', $allowed_category_ids));
+
+        $query = $this->db->query("
+            SELECT p.product_id, pd.name, p.model, p.price, p.quantity, p.image,
+                   r.reverb_listing_id, r.condition_uuid, r.reverb_category_uuid
+            FROM `" . DB_PREFIX . "product` p
+            INNER JOIN `" . DB_PREFIX . "product_description` pd
+                ON pd.product_id = p.product_id AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
+            INNER JOIN `" . DB_PREFIX . "product_to_category` ptc
+                ON ptc.product_id = p.product_id AND ptc.category_id IN ($ids)
+            INNER JOIN `" . DB_PREFIX . "reverb_product_map` r
+                ON r.product_id = p.product_id AND r.sync_enabled = 1
+            WHERE p.status = 1
+            GROUP BY p.product_id
+        ");
+
+        return $query->rows;
+    }
+
+    // -------------------------------------------------------------------------
+    // Product images
+    // -------------------------------------------------------------------------
+
+    public function getProductImages($product_id) {
+        $images = [];
+
+        $product_query = $this->db->query("
+            SELECT image FROM `" . DB_PREFIX . "product`
+            WHERE product_id = '" . (int)$product_id . "'
+        ");
+        if ($product_query->num_rows && !empty($product_query->row['image'])) {
+            $images[] = $product_query->row['image'];
+        }
+
+        $gallery_query = $this->db->query("
+            SELECT image FROM `" . DB_PREFIX . "product_image`
+            WHERE product_id = '" . (int)$product_id . "'
+            ORDER BY sort_order ASC
+        ");
+        foreach ($gallery_query->rows as $row) {
+            $images[] = $row['image'];
+        }
+
+        return $images;
+    }
+
+    // -------------------------------------------------------------------------
+    // Category mappings (OC category → Reverb category UUID)
+    // -------------------------------------------------------------------------
+
+    public function getCategoryMappings() {
+        $raw = $this->config->get('module_reverb_category_mappings');
+        return $raw ? json_decode($raw, true) : [];
+    }
+
+    public function saveCategoryMappings(array $mappings) {
+        $this->load->model('setting/setting');
+        $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_category_mappings', json_encode($mappings));
+    }
+
+    // -------------------------------------------------------------------------
+    // Reverb metadata cache (conditions + categories)
+    // -------------------------------------------------------------------------
+
+    public function getListingConditions() {
+        $cached   = $this->config->get('module_reverb_conditions_cache');
+        $cached_at = (int)$this->config->get('module_reverb_conditions_cached_at');
+
+        if ($cached && (time() - $cached_at) < 86400) {
+            return json_decode($cached, true);
+        }
+
+        try {
+            $api  = $this->getApi();
+            $resp = $api->getListingConditions();
+            $conditions = isset($resp['conditions']) ? $resp['conditions'] : [];
+
+            $this->load->model('setting/setting');
+            $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_conditions_cache', json_encode($conditions));
+            $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_conditions_cached_at', time());
+
+            return $conditions;
+        } catch (Exception $e) {
+            return $cached ? json_decode($cached, true) : [];
+        }
+    }
+
+    public function getReverbCategories() {
+        $cached    = $this->config->get('module_reverb_categories_cache');
+        $cached_at = (int)$this->config->get('module_reverb_categories_cached_at');
+
+        if ($cached && (time() - $cached_at) < 86400) {
+            return json_decode($cached, true);
+        }
+
+        try {
+            $api  = $this->getApi();
+            $resp = $api->getCategories();
+            $categories = isset($resp['categories']) ? $resp['categories'] : [];
+
+            $this->load->model('setting/setting');
+            $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_categories_cache', json_encode($categories));
+            $this->model_setting_setting->editSettingValue('module_reverb', 'module_reverb_categories_cached_at', time());
+
+            return $categories;
+        } catch (Exception $e) {
+            return $cached ? json_decode($cached, true) : [];
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // Sync log
+    // -------------------------------------------------------------------------
+
+    public function log($product_id, $direction, $status, $message) {
+        $this->db->query("
+            INSERT INTO `" . DB_PREFIX . "reverb_sync_log`
+            (`product_id`, `direction`, `status`, `message`, `created_at`)
+            VALUES (
+                '" . (int)$product_id . "',
+                '" . $this->db->escape($direction) . "',
+                '" . $this->db->escape($status) . "',
+                '" . $this->db->escape(substr($message, 0, 65535)) . "',
+                NOW()
+            )
+        ");
+    }
+
+    public function getSyncLog($limit = 100) {
+        $query = $this->db->query("
+            SELECT l.*, pd.name AS product_name
+            FROM `" . DB_PREFIX . "reverb_sync_log` l
+            LEFT JOIN `" . DB_PREFIX . "product_description` pd
+                ON pd.product_id = l.product_id
+                AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
+            ORDER BY l.created_at DESC
+            LIMIT " . (int)$limit
+        );
+        return $query->rows;
+    }
+
+    // -------------------------------------------------------------------------
+    // Sync helpers
+    // -------------------------------------------------------------------------
+
+    /**
+     * Push a single product to Reverb. Creates or updates the listing and uploads images.
+     *
+     * @param array $product     Product row (product_id, name, model, price, quantity, image, ...).
+     * @param array $reverb_data Row from reverb_product_map.
+     * @param array $settings    Global Reverb settings array.
+     * @return string            The Reverb listing ID.
+     */
+    public function syncProductToReverb(array $product, array $reverb_data, array $settings) {
+        $this->load->library('reverb/ReverbApi');
+        $this->load->library('reverb/ProductMapper');
+
+        $api     = $this->getApi();
+        $payload = ProductMapper::toReverb($product, $reverb_data, $settings);
+
+        $listing_id = !empty($reverb_data['reverb_listing_id']) ? $reverb_data['reverb_listing_id'] : null;
+
+        if ($listing_id) {
+            $api->updateListing($listing_id, $payload);
+        } else {
+            $response   = $api->createListing($payload);
+            $listing_id = $response['id'] ?? ($response['listing']['id'] ?? null);
+            if (!$listing_id) {
+                throw new RuntimeException('Reverb did not return a listing ID after create.');
+            }
+            $this->updateListingId($product['product_id'], $listing_id);
+        }
+
+        // Upload images
+        $images    = $this->getProductImages($product['product_id']);
+        $store_url = $settings['store_url'] ?? '';
+        foreach (ProductMapper::buildPhotoPayloads($images, $store_url) as $photo) {
+            try {
+                $api->uploadPhoto($listing_id, $photo['image_url']);
+            } catch (Exception $e) {
+                // Non-fatal: log but continue
+                $this->log($product['product_id'], 'push', 'error', 'Photo upload failed: ' . $e->getMessage());
+            }
+        }
+
+        return $listing_id;
+    }
+
+    // -------------------------------------------------------------------------
+    // Utility
+    // -------------------------------------------------------------------------
+
+    private function getApi() {
+        $token = $this->config->get('module_reverb_api_token');
+        if (empty($token)) {
+            throw new RuntimeException('Reverb API token is not configured.');
+        }
+        $this->load->library('reverb/ReverbApi');
+        return new ReverbApi($token);
+    }
+}

+ 265 - 0
upload/admin/view/template/extension/module/reverb.twig

@@ -0,0 +1,265 @@
+{{ header }}{{ column_left }}
+<div id="content">
+  <div class="page-header">
+    <div class="container-fluid">
+      <div class="pull-right">
+        <button type="submit" form="form-reverb" class="btn btn-primary">
+          <i class="fa fa-floppy-o"></i> {{ button_save }}
+        </button>
+        <a href="{{ cancel }}" class="btn btn-default">
+          <i class="fa fa-reply"></i> {{ button_cancel }}
+        </a>
+      </div>
+      <h1>{{ heading_title }}</h1>
+      <ul class="breadcrumb">
+        {% for breadcrumb in breadcrumbs %}
+        <li><a href="{{ breadcrumb.href }}">{{ breadcrumb.text }}</a></li>
+        {% endfor %}
+      </ul>
+    </div>
+  </div>
+
+  <div class="container-fluid">
+    {% if error_warning %}
+    <div class="alert alert-danger alert-dismissible">
+      <i class="fa fa-exclamation-circle"></i> {{ error_warning }}
+      <button type="button" class="close" data-dismiss="alert">&times;</button>
+    </div>
+    {% endif %}
+    {% if success %}
+    <div class="alert alert-success alert-dismissible">
+      <i class="fa fa-check-circle"></i> {{ success }}
+      <button type="button" class="close" data-dismiss="alert">&times;</button>
+    </div>
+    {% endif %}
+
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <h3 class="panel-title"><i class="fa fa-pencil"></i> {{ text_edit }}</h3>
+      </div>
+      <div class="panel-body">
+
+        <ul class="nav nav-tabs">
+          <li class="active"><a href="#tab-settings" data-toggle="tab">{{ tab_settings }}</a></li>
+          <li><a href="#tab-categories" data-toggle="tab">{{ tab_categories }}</a></li>
+          <li><a href="#tab-log" data-toggle="tab">{{ tab_log }}</a></li>
+        </ul>
+
+        <form action="{{ action }}" method="post" enctype="multipart/form-data" id="form-reverb" class="form-horizontal">
+
+          <!-- ================================================================ -->
+          <!-- TAB: Settings -->
+          <!-- ================================================================ -->
+          <div class="tab-content">
+          <div class="tab-pane active" id="tab-settings">
+
+            <h4>{{ text_api_settings }}</h4>
+
+            <div class="form-group required">
+              <label class="col-sm-2 control-label">{{ entry_api_token }}</label>
+              <div class="col-sm-10">
+                <input type="text" name="module_reverb_api_token"
+                       value="{{ module_reverb_api_token }}"
+                       class="form-control" placeholder="e.g. abc123..." />
+                <span class="help-block">{{ help_api_token }}</span>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <label class="col-sm-2 control-label">{{ entry_status }}</label>
+              <div class="col-sm-10">
+                <select name="module_reverb_status" class="form-control">
+                  <option value="1" {% if module_reverb_status %}selected{% endif %}>{{ text_enabled }}</option>
+                  <option value="0" {% if not module_reverb_status %}selected{% endif %}>{{ text_disabled }}</option>
+                </select>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <label class="col-sm-2 control-label">{{ entry_sync_direction }}</label>
+              <div class="col-sm-10">
+                <select name="module_reverb_sync_direction" class="form-control">
+                  <option value="push" {% if module_reverb_sync_direction == 'push' %}selected{% endif %}>{{ text_sync_push }}</option>
+                  <option value="both" {% if module_reverb_sync_direction == 'both' %}selected{% endif %}>{{ text_sync_both }}</option>
+                </select>
+              </div>
+            </div>
+
+            <h4>{{ text_shipping_settings }}</h4>
+
+            <div class="form-group">
+              <label class="col-sm-2 control-label">{{ entry_shipping_domestic }}</label>
+              <div class="col-sm-4">
+                <div class="input-group">
+                  <span class="input-group-addon">$</span>
+                  <input type="text" name="module_reverb_shipping_domestic"
+                         value="{{ module_reverb_shipping_domestic }}"
+                         class="form-control" />
+                </div>
+                <span class="help-block">{{ help_shipping_domestic }}</span>
+              </div>
+            </div>
+
+            <div class="form-group">
+              <label class="col-sm-2 control-label">{{ entry_shipping_international }}</label>
+              <div class="col-sm-4">
+                <div class="input-group">
+                  <span class="input-group-addon">$</span>
+                  <input type="text" name="module_reverb_shipping_international"
+                         value="{{ module_reverb_shipping_international }}"
+                         class="form-control" />
+                </div>
+                <span class="help-block">{{ help_shipping_international }}</span>
+              </div>
+            </div>
+
+            <h4>{{ text_sync_settings }}</h4>
+
+            <div class="form-group">
+              <label class="col-sm-2 control-label">Categories</label>
+              <div class="col-sm-10">
+                <select name="module_reverb_sync_categories[]" multiple class="form-control" size="10">
+                  {% for category in categories %}
+                  <option value="{{ category.category_id }}"
+                    {% if category.category_id in module_reverb_sync_categories %}selected{% endif %}>
+                    {{ category.name|raw }}
+                  </option>
+                  {% endfor %}
+                </select>
+                <span class="help-block">{{ help_sync_categories }}</span>
+              </div>
+            </div>
+
+            <h4>{{ text_manual_sync }}</h4>
+
+            <div class="form-group">
+              <div class="col-sm-offset-2 col-sm-10">
+                <button type="button" id="btn-sync-now" class="btn btn-warning">
+                  <i class="fa fa-refresh"></i> {{ button_sync_now }}
+                </button>
+                <span id="sync-result" class="help-block" style="display:none;"></span>
+              </div>
+            </div>
+
+          </div><!-- #tab-settings -->
+
+          <!-- ================================================================ -->
+          <!-- TAB: Category Mapping -->
+          <!-- ================================================================ -->
+          <div class="tab-pane" id="tab-categories">
+            <p class="help-block">{{ text_category_mapping_help }}</p>
+
+            {% if module_reverb_sync_categories is empty %}
+            <div class="alert alert-info">{{ text_no_categories }}</div>
+            {% else %}
+            <table class="table table-bordered table-hover">
+              <thead>
+                <tr>
+                  <th>{{ column_oc_category }}</th>
+                  <th>{{ column_reverb_category }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                {% for category in categories %}
+                  {% if category.category_id in module_reverb_sync_categories %}
+                  <tr>
+                    <td>{{ category.name|raw }}</td>
+                    <td>
+                      <select name="module_reverb_category_mappings[{{ category.category_id }}]"
+                              class="form-control">
+                        <option value="">-- Select Reverb Category --</option>
+                        {% for rc in reverb_categories %}
+                        <option value="{{ rc.uuid }}"
+                          {% if module_reverb_category_mappings[category.category_id] is defined
+                               and module_reverb_category_mappings[category.category_id] == rc.uuid %}
+                               selected
+                          {% endif %}>
+                          {{ rc.full_name ?? rc.name }}
+                        </option>
+                        {% endfor %}
+                      </select>
+                    </td>
+                  </tr>
+                  {% endif %}
+                {% endfor %}
+              </tbody>
+            </table>
+            {% endif %}
+          </div><!-- #tab-categories -->
+
+          <!-- ================================================================ -->
+          <!-- TAB: Sync Log -->
+          <!-- ================================================================ -->
+          <div class="tab-pane" id="tab-log">
+            {% if sync_log is empty %}
+            <p>{{ text_no_log }}</p>
+            {% else %}
+            <table class="table table-bordered table-hover table-striped">
+              <thead>
+                <tr>
+                  <th>{{ column_date }}</th>
+                  <th>{{ column_product }}</th>
+                  <th>{{ column_direction }}</th>
+                  <th>{{ column_status }}</th>
+                  <th>{{ column_message }}</th>
+                </tr>
+              </thead>
+              <tbody>
+                {% for entry in sync_log %}
+                <tr class="{{ entry.status == 'error' ? 'danger' : '' }}">
+                  <td>{{ entry.created_at }}</td>
+                  <td>{{ entry.product_name ?? entry.product_id }}</td>
+                  <td>{{ entry.direction == 'push' ? text_push : text_pull }}</td>
+                  <td>
+                    <span class="label label-{{ entry.status == 'success' ? 'success' : 'danger' }}">
+                      {{ entry.status == 'success' ? text_success : text_error }}
+                    </span>
+                  </td>
+                  <td>{{ entry.message }}</td>
+                </tr>
+                {% endfor %}
+              </tbody>
+            </table>
+            {% endif %}
+          </div><!-- #tab-log -->
+
+          </div><!-- .tab-content -->
+        </form>
+
+      </div><!-- .panel-body -->
+    </div><!-- .panel -->
+  </div><!-- .container-fluid -->
+</div><!-- #content -->
+
+<script>
+$(function() {
+    $('#btn-sync-now').on('click', function() {
+        var $btn = $(this);
+        var $result = $('#sync-result');
+
+        $btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i> Syncing...');
+        $result.hide();
+
+        $.ajax({
+            url: '{{ sync_url }}',
+            type: 'GET',
+            dataType: 'json',
+            success: function(data) {
+                if (data.success) {
+                    $result.removeClass('text-danger').addClass('text-success').text(data.message).show();
+                } else {
+                    $result.removeClass('text-success').addClass('text-danger').text(data.error).show();
+                }
+            },
+            error: function() {
+                $result.removeClass('text-success').addClass('text-danger').text('Sync request failed.').show();
+            },
+            complete: function() {
+                $btn.prop('disabled', false).html('<i class="fa fa-refresh"></i> {{ button_sync_now }}');
+            }
+        });
+    });
+});
+</script>
+
+{{ footer }}

+ 88 - 0
upload/admin/view/template/extension/module/reverb_product.twig

@@ -0,0 +1,88 @@
+{#
+  Per-product Reverb tab content.
+  Injected into admin/view/template/catalog/product.twig via reverb.ocmod.xml.
+  Variables populated by the OCMOD PHP patch in admin/controller/catalog/product.php.
+#}
+<div class="tab-pane" id="tab-reverb">
+  <div class="panel panel-default" style="margin-top: 15px;">
+    <div class="panel-body">
+
+      {# Sync toggle #}
+      <div class="form-group">
+        <label class="col-sm-2 control-label">List on Reverb</label>
+        <div class="col-sm-10">
+          {# Hidden field ensures unchecked checkbox sends 0 #}
+          <input type="hidden" name="reverb_sync_enabled" value="0" />
+          <label class="checkbox-inline">
+            <input type="checkbox" name="reverb_sync_enabled" value="1" id="reverb-sync-enabled"
+                   {% if reverb_sync_enabled %}checked{% endif %} />
+            Enable sync for this product
+          </label>
+          <p class="help-block">
+            Enable to include this product in Reverb syncs. The product must also be
+            in an allowed category (configured in the Reverb module settings).
+          </p>
+        </div>
+      </div>
+
+      {# Condition #}
+      <div class="form-group">
+        <label class="col-sm-2 control-label">Condition</label>
+        <div class="col-sm-4">
+          <select name="reverb_condition_uuid" id="reverb-condition-uuid" class="form-control">
+            <option value="">-- Select Condition --</option>
+            {% for condition in reverb_conditions %}
+            <option value="{{ condition.uuid }}"
+              {% if reverb_condition_uuid == condition.uuid %}selected{% endif %}>
+              {{ condition.display_name }}
+            </option>
+            {% endfor %}
+          </select>
+          <p class="help-block">Required by Reverb. Describes the physical state of the item.</p>
+        </div>
+      </div>
+
+      {# Reverb Category override #}
+      <div class="form-group">
+        <label class="col-sm-2 control-label">Reverb Category</label>
+        <div class="col-sm-6">
+          <select name="reverb_category_uuid" id="reverb-category-uuid" class="form-control">
+            <option value="">-- Use category mapping default --</option>
+            {% for rc in reverb_oc_category_mappings %}
+              {% if rc.reverb_category_uuid %}
+              <option value="{{ rc.reverb_category_uuid }}"
+                {% if reverb_category_uuid == rc.reverb_category_uuid %}selected{% endif %}>
+                {{ rc.reverb_category_name ?? rc.reverb_category_uuid }}
+              </option>
+              {% endif %}
+            {% endfor %}
+          </select>
+          <p class="help-block">Leave blank to use the default mapping from the Reverb module settings.</p>
+        </div>
+      </div>
+
+      {# Reverb listing link (read-only, shown once synced) #}
+      {% if reverb_listing_id %}
+      <div class="form-group">
+        <label class="col-sm-2 control-label">Reverb Listing</label>
+        <div class="col-sm-10">
+          <p class="form-control-static">
+            <a href="https://reverb.com/item/{{ reverb_listing_id }}" target="_blank" rel="noopener">
+              <i class="fa fa-external-link"></i>
+              View on Reverb (ID: {{ reverb_listing_id }})
+            </a>
+          </p>
+        </div>
+      </div>
+      {% else %}
+      <div class="form-group">
+        <label class="col-sm-2 control-label">Reverb Listing</label>
+        <div class="col-sm-10">
+          <p class="form-control-static text-muted">Not yet synced to Reverb.</p>
+        </div>
+      </div>
+      {% endif %}
+
+    </div>
+  </div>
+</div>

+ 284 - 0
upload/catalog/controller/extension/module/reverb.php

@@ -0,0 +1,284 @@
+<?php
+class ControllerExtensionModuleReverb extends Controller {
+
+    // -------------------------------------------------------------------------
+    // Webhook receiver
+    // URL: index.php?route=extension/module/reverb/webhook
+    // -------------------------------------------------------------------------
+
+    public function webhook() {
+        $raw = file_get_contents('php://input');
+
+        // Validate Reverb signature if a secret is configured
+        $secret = $this->config->get('module_reverb_webhook_secret');
+        if ($secret) {
+            $signature = $_SERVER['HTTP_REVERB_SIGNATURE'] ?? '';
+            if (!hash_equals(hash_hmac('sha256', $raw, $secret), $signature)) {
+                http_response_code(401);
+                exit('Unauthorized');
+            }
+        }
+
+        $payload = json_decode($raw, true);
+        if (!$payload || empty($payload['event'])) {
+            http_response_code(400);
+            exit('Bad Request');
+        }
+
+        $this->load->model('extension/module/reverb');
+
+        try {
+            switch ($payload['event']) {
+                case 'listing/update':
+                    $this->handleListingUpdate($payload);
+                    break;
+
+                case 'order/create':
+                    $this->handleOrderCreate($payload);
+                    break;
+
+                default:
+                    // Unknown event — acknowledge silently
+                    break;
+            }
+        } catch (Exception $e) {
+            $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Webhook error: ' . $e->getMessage());
+            http_response_code(500);
+            exit('Internal Error');
+        }
+
+        http_response_code(200);
+        exit('OK');
+    }
+
+    // -------------------------------------------------------------------------
+    // Cron endpoint — polls Reverb for updates (fallback when webhooks not used)
+    // URL: index.php?route=extension/module/reverb/cron&cron_token=SECRET
+    // -------------------------------------------------------------------------
+
+    public function cron() {
+        $cron_token = $this->config->get('module_reverb_cron_token');
+        if ($cron_token && ($this->request->get['cron_token'] ?? '') !== $cron_token) {
+            http_response_code(403);
+            exit('Forbidden');
+        }
+
+        $this->load->model('extension/module/reverb');
+
+        $direction = $this->config->get('module_reverb_sync_direction') ?? 'push';
+
+        if ($direction === 'both') {
+            $this->pollListingUpdates();
+            $this->pollOrders();
+        }
+
+        // Push any products that haven't been synced yet
+        $this->pushPendingProducts();
+
+        exit('Done');
+    }
+
+    // -------------------------------------------------------------------------
+    // Internal: listing update (Reverb → OpenCart)
+    // -------------------------------------------------------------------------
+
+    private function handleListingUpdate(array $payload) {
+        $listing = $payload['listing'] ?? [];
+        if (empty($listing['id'])) {
+            return;
+        }
+
+        $listing_id = (string)$listing['id'];
+
+        $query = $this->db->query("
+            SELECT product_id FROM `" . DB_PREFIX . "reverb_product_map`
+            WHERE reverb_listing_id = '" . $this->db->escape($listing_id) . "'
+        ");
+
+        if (!$query->num_rows) {
+            return;
+        }
+
+        $product_id = (int)$query->row['product_id'];
+
+        $this->load->library('reverb/ProductMapper');
+        $updates = ProductMapper::fromReverb($listing);
+
+        if (!empty($updates)) {
+            $this->load->model('catalog/product');
+            $this->model_catalog_product->editProduct($product_id, $updates);
+            $this->model_extension_module_reverb->log($product_id, 'pull', 'success', 'Updated from Reverb listing ' . $listing_id);
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // Internal: new order (Reverb → OpenCart)
+    // -------------------------------------------------------------------------
+
+    private function handleOrderCreate(array $payload) {
+        $reverb_order = $payload['order'] ?? [];
+        if (empty($reverb_order['order_number'])) {
+            return;
+        }
+
+        $order_number = $reverb_order['order_number'];
+
+        // Check for duplicate — avoid creating the same order twice
+        $check = $this->db->query("
+            SELECT order_id FROM `" . DB_PREFIX . "order`
+            WHERE comment LIKE '%" . $this->db->escape('Reverb Order #' . $order_number) . "%'
+            LIMIT 1
+        ");
+        if ($check->num_rows) {
+            return;
+        }
+
+        // Find the matching OC product by SKU or listing_id
+        $listing_id = (string)($reverb_order['listing']['id'] ?? '');
+        $oc_product = $this->findProductByListingId($listing_id);
+
+        if (!$oc_product) {
+            $sku = $reverb_order['listing']['sku'] ?? '';
+            $oc_product = $sku ? $this->findProductBySku($sku) : null;
+        }
+
+        if (!$oc_product) {
+            // Create a placeholder product entry so the order can still be recorded
+            $oc_product = [
+                'product_id' => 0,
+                'name'       => $reverb_order['listing']['title'] ?? 'Reverb Product',
+                'model'      => $reverb_order['listing']['sku'] ?? '',
+                'price'      => $reverb_order['total']['amount'] ?? 0,
+            ];
+        }
+
+        $this->load->library('reverb/OrderMapper');
+
+        $store_info = [
+            'store_id'       => $this->config->get('config_store_id') ?? 0,
+            'store_name'     => $this->config->get('config_name') ?? '',
+            'store_url'      => $this->config->get('config_url') ?? '',
+            'language_id'    => $this->config->get('config_language_id') ?? 1,
+            'currency_id'    => 1,
+            'currency_code'  => $this->config->get('config_currency') ?? 'AUD',
+            'currency_value' => 1,
+        ];
+
+        $order_data = OrderMapper::toOpenCart($reverb_order, $oc_product, $store_info);
+
+        $this->load->model('checkout/order');
+        $order_id = $this->model_checkout_order->addOrder($order_data);
+
+        // Decrease OC stock for the matched product
+        if ($oc_product['product_id']) {
+            $this->db->query("
+                UPDATE `" . DB_PREFIX . "product`
+                SET quantity = GREATEST(0, quantity - " . (int)($reverb_order['quantity'] ?? 1) . ")
+                WHERE product_id = '" . (int)$oc_product['product_id'] . "'
+            ");
+        }
+
+        $this->model_extension_module_reverb->log(
+            $oc_product['product_id'],
+            'pull',
+            'success',
+            'Created OC order #' . $order_id . ' from Reverb order #' . $order_number
+        );
+    }
+
+    // -------------------------------------------------------------------------
+    // Internal: cron helpers
+    // -------------------------------------------------------------------------
+
+    private function pollListingUpdates() {
+        try {
+            $this->load->library('reverb/ReverbApi');
+            $token = $this->config->get('module_reverb_api_token');
+            if (!$token) return;
+
+            $api      = new ReverbApi($token);
+            $response = $api->getListings();
+            $listings = $response['listings'] ?? [];
+
+            foreach ($listings as $listing) {
+                $this->handleListingUpdate(['listing' => $listing]);
+            }
+        } catch (Exception $e) {
+            $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Poll listings failed: ' . $e->getMessage());
+        }
+    }
+
+    private function pollOrders() {
+        try {
+            $this->load->library('reverb/ReverbApi');
+            $token = $this->config->get('module_reverb_api_token');
+            if (!$token) return;
+
+            $api      = new ReverbApi($token);
+            $response = $api->getOrders();
+            $orders   = $response['orders'] ?? [];
+
+            foreach ($orders as $order) {
+                $this->handleOrderCreate(['order' => $order]);
+            }
+        } catch (Exception $e) {
+            $this->model_extension_module_reverb->log(0, 'pull', 'error', 'Poll orders failed: ' . $e->getMessage());
+        }
+    }
+
+    private function pushPendingProducts() {
+        $this->load->model('setting/setting');
+        $allowed_categories = $this->config->get('module_reverb_sync_categories') ?? [];
+        if (empty($allowed_categories)) return;
+
+        $settings = [
+            'api_token'              => $this->config->get('module_reverb_api_token'),
+            'shipping_domestic'      => $this->config->get('module_reverb_shipping_domestic') ?? '0',
+            'shipping_international' => $this->config->get('module_reverb_shipping_international') ?? '0',
+            'currency'               => $this->config->get('config_currency') ?? 'AUD',
+            'store_url'              => $this->config->get('config_url') ?? '',
+        ];
+
+        $products = $this->model_extension_module_reverb->getSyncEnabledProducts((array)$allowed_categories);
+
+        foreach ($products as $product) {
+            try {
+                $this->model_extension_module_reverb->syncProductToReverb($product, $product, $settings);
+                $this->model_extension_module_reverb->log($product['product_id'], 'push', 'success', 'Cron sync: ' . $product['name']);
+            } catch (Exception $e) {
+                $this->model_extension_module_reverb->log($product['product_id'], 'push', 'error', $e->getMessage());
+            }
+        }
+    }
+
+    // -------------------------------------------------------------------------
+    // DB lookups
+    // -------------------------------------------------------------------------
+
+    private function findProductByListingId($listing_id) {
+        if (!$listing_id) return null;
+        $query = $this->db->query("
+            SELECT p.product_id, pd.name, p.model, p.price
+            FROM `" . DB_PREFIX . "reverb_product_map` r
+            JOIN `" . DB_PREFIX . "product` p ON p.product_id = r.product_id
+            JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
+                AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
+            WHERE r.reverb_listing_id = '" . $this->db->escape($listing_id) . "'
+            LIMIT 1
+        ");
+        return $query->num_rows ? $query->row : null;
+    }
+
+    private function findProductBySku($sku) {
+        if (!$sku) return null;
+        $query = $this->db->query("
+            SELECT p.product_id, pd.name, p.model, p.price
+            FROM `" . DB_PREFIX . "product` p
+            JOIN `" . DB_PREFIX . "product_description` pd ON pd.product_id = p.product_id
+                AND pd.language_id = '" . (int)$this->config->get('config_language_id') . "'
+            WHERE p.model = '" . $this->db->escape($sku) . "'
+            LIMIT 1
+        ");
+        return $query->num_rows ? $query->row : null;
+    }
+}

+ 142 - 0
upload/system/library/reverb/OrderMapper.php

@@ -0,0 +1,142 @@
+<?php
+class OrderMapper {
+
+    /**
+     * Convert a Reverb order object into an OpenCart addOrder() data array.
+     *
+     * @param array $reverb_order   Order object from GET /my/orders/{number}.
+     * @param array $oc_product     Matching OC product row (product_id, name, model, price).
+     * @param array $store_info     ['store_id', 'store_name', 'store_url', 'language_id',
+     *                               'currency_id', 'currency_code', 'currency_value'].
+     * @param int   $order_status_id  Default order status ID (typically 1 = Pending).
+     * @return array                  Ready to pass to model_checkout_order->addOrder().
+     */
+    public static function toOpenCart(array $reverb_order, array $oc_product, array $store_info, $order_status_id = 1) {
+        // --- Buyer name split ---
+        $buyer_name  = isset($reverb_order['buyer_name']) ? $reverb_order['buyer_name'] : 'Reverb Buyer';
+        $name_parts  = explode(' ', trim($buyer_name), 2);
+        $first_name  = $name_parts[0];
+        $last_name   = isset($name_parts[1]) ? $name_parts[1] : '';
+
+        $buyer_email = isset($reverb_order['buyer_email']) ? $reverb_order['buyer_email'] : '';
+
+        // --- Shipping address ---
+        $ship = isset($reverb_order['shipping_address']) ? $reverb_order['shipping_address'] : [];
+
+        $ship_name    = isset($ship['name']) ? $ship['name'] : $buyer_name;
+        $ship_parts   = explode(' ', trim($ship_name), 2);
+        $ship_first   = $ship_parts[0];
+        $ship_last    = isset($ship_parts[1]) ? $ship_parts[1] : '';
+        $ship_addr1   = isset($ship['street_address']) ? $ship['street_address'] : '';
+        $ship_city    = isset($ship['locality'])       ? $ship['locality']       : '';
+        $ship_state   = isset($ship['region'])         ? $ship['region']         : '';
+        $ship_post    = isset($ship['postal_code'])    ? $ship['postal_code']    : '';
+        $ship_country = isset($ship['country_code'])   ? strtoupper($ship['country_code']) : 'AU';
+
+        // --- Totals ---
+        $total_amount = isset($reverb_order['total']['amount'])
+            ? (float)$reverb_order['total']['amount']
+            : (float)$oc_product['price'] * (isset($reverb_order['quantity']) ? (int)$reverb_order['quantity'] : 1);
+
+        $quantity = isset($reverb_order['quantity']) ? (int)$reverb_order['quantity'] : 1;
+
+        $order_data = [
+            // Store
+            'invoice_prefix'        => 'INV-',
+            'invoice_no'            => 0,
+            'store_id'              => $store_info['store_id'],
+            'store_name'            => $store_info['store_name'],
+            'store_url'             => $store_info['store_url'],
+            // Customer (guest)
+            'customer_id'           => 0,
+            'customer_group_id'     => 1,
+            'firstname'             => $first_name,
+            'lastname'              => $last_name,
+            'email'                 => $buyer_email,
+            'telephone'             => '',
+            'fax'                   => '',
+            'custom_field'          => [],
+            // Payment (use shipping address for Reverb orders)
+            'payment_firstname'     => $ship_first,
+            'payment_lastname'      => $ship_last,
+            'payment_company'       => '',
+            'payment_address_1'     => $ship_addr1,
+            'payment_address_2'     => '',
+            'payment_city'          => $ship_city,
+            'payment_postcode'      => $ship_post,
+            'payment_country'       => $ship_country,
+            'payment_country_id'    => 0,
+            'payment_zone'          => $ship_state,
+            'payment_zone_id'       => 0,
+            'payment_address_format'=> '',
+            'payment_custom_field'  => [],
+            'payment_method'        => 'Reverb',
+            'payment_code'          => 'reverb',
+            // Shipping
+            'shipping_firstname'    => $ship_first,
+            'shipping_lastname'     => $ship_last,
+            'shipping_company'      => '',
+            'shipping_address_1'    => $ship_addr1,
+            'shipping_address_2'    => '',
+            'shipping_city'         => $ship_city,
+            'shipping_postcode'     => $ship_post,
+            'shipping_country'      => $ship_country,
+            'shipping_country_id'   => 0,
+            'shipping_zone'         => $ship_state,
+            'shipping_zone_id'      => 0,
+            'shipping_address_format' => '',
+            'shipping_custom_field' => [],
+            'shipping_method'       => 'Reverb Shipping',
+            'shipping_code'         => 'reverb.reverb',
+            // Misc
+            'comment'               => 'Reverb Order #' . ($reverb_order['order_number'] ?? ''),
+            'total'                 => $total_amount,
+            'affiliate_id'          => 0,
+            'commission'            => 0,
+            'language_id'           => $store_info['language_id'],
+            'currency_id'           => $store_info['currency_id'],
+            'currency_code'         => $store_info['currency_code'],
+            'currency_value'        => $store_info['currency_value'],
+            'ip'                    => '',
+            'forwarded_ip'          => '',
+            'user_agent'            => '',
+            'accept_language'       => '',
+            'order_status_id'       => $order_status_id,
+            // Products
+            'products' => [
+                [
+                    'product_id'   => (int)$oc_product['product_id'],
+                    'name'         => $oc_product['name'],
+                    'model'        => $oc_product['model'],
+                    'quantity'     => $quantity,
+                    'price'        => (float)$oc_product['price'],
+                    'total'        => (float)$oc_product['price'] * $quantity,
+                    'tax'          => 0.00,
+                    'reward'       => 0,
+                    'option'       => [],
+                    'download'     => [],
+                    'subscription' => '',
+                    'tax_class_id' => 0,
+                ],
+            ],
+            // Totals
+            'totals' => [
+                [
+                    'code'       => 'sub_total',
+                    'title'      => 'Sub-Total',
+                    'value'      => $total_amount,
+                    'sort_order' => 1,
+                ],
+                [
+                    'code'       => 'total',
+                    'title'      => 'Total',
+                    'value'      => $total_amount,
+                    'sort_order' => 9,
+                ],
+            ],
+            'vouchers' => [],
+        ];
+
+        return $order_data;
+    }
+}

+ 104 - 0
upload/system/library/reverb/ProductMapper.php

@@ -0,0 +1,104 @@
+<?php
+class ProductMapper {
+
+    /**
+     * Build a Reverb listing payload from an OpenCart product array.
+     *
+     * @param array $product     Row from oc_product joined with oc_product_description.
+     * @param array $reverb_data Row from oc_reverb_product_map (condition_uuid, reverb_category_uuid).
+     * @param array $settings    Global Reverb settings (currency, shipping_domestic, shipping_international, store_image_url).
+     * @return array             Ready to POST/PUT to /listings.
+     */
+    public static function toReverb(array $product, array $reverb_data, array $settings) {
+        $currency = !empty($settings['currency']) ? $settings['currency'] : 'AUD';
+
+        $payload = [
+            'title'       => $product['name'],
+            'description' => strip_tags($product['description']),
+            'sku'         => $product['model'],
+            'inventory'   => max(0, (int)$product['quantity']),
+            'price'       => [
+                'amount'   => number_format((float)$product['price'], 2, '.', ''),
+                'currency' => $currency,
+            ],
+        ];
+
+        if (!empty($reverb_data['condition_uuid'])) {
+            $payload['condition'] = ['uuid' => $reverb_data['condition_uuid']];
+        }
+
+        if (!empty($reverb_data['reverb_category_uuid'])) {
+            $payload['categories'] = [['uuid' => $reverb_data['reverb_category_uuid']]];
+        }
+
+        // Shipping rates
+        $rates = [];
+        if (!empty($settings['shipping_domestic'])) {
+            $rates[] = [
+                'region_code' => 'AU',
+                'rate'        => [
+                    'amount'   => number_format((float)$settings['shipping_domestic'], 2, '.', ''),
+                    'currency' => $currency,
+                ],
+            ];
+        }
+        if (!empty($settings['shipping_international'])) {
+            $rates[] = [
+                'region_code' => 'XX',
+                'rate'        => [
+                    'amount'   => number_format((float)$settings['shipping_international'], 2, '.', ''),
+                    'currency' => $currency,
+                ],
+            ];
+        }
+        if ($rates) {
+            $payload['shipping'] = ['rates' => $rates];
+        }
+
+        return $payload;
+    }
+
+    /**
+     * Extract updatable OC product fields from a Reverb listing payload
+     * (used for Reverb → OpenCart sync).
+     *
+     * @param array $listing  A listing object from the Reverb API.
+     * @return array          Partial product data to merge into OC.
+     */
+    public static function fromReverb(array $listing) {
+        $data = [];
+
+        if (isset($listing['title'])) {
+            $data['name'] = $listing['title'];
+        }
+        if (isset($listing['description'])) {
+            $data['description'] = $listing['description'];
+        }
+        if (isset($listing['price']['amount'])) {
+            $data['price'] = (float)$listing['price']['amount'];
+        }
+        if (isset($listing['inventory'])) {
+            $data['quantity'] = (int)$listing['inventory'];
+        }
+
+        return $data;
+    }
+
+    /**
+     * Build photo upload payloads for a product's images.
+     *
+     * @param array  $images      Array of image paths relative to OC image dir.
+     * @param string $store_url   Full base URL of the store (e.g. https://example.com/).
+     * @return array              Array of ['image_url' => '...'] payloads.
+     */
+    public static function buildPhotoPayloads(array $images, $store_url) {
+        $payloads = [];
+        $base = rtrim($store_url, '/') . '/image/';
+        foreach ($images as $path) {
+            if (!empty($path)) {
+                $payloads[] = ['image_url' => $base . ltrim($path, '/')];
+            }
+        }
+        return $payloads;
+    }
+}

+ 150 - 0
upload/system/library/reverb/ReverbApi.php

@@ -0,0 +1,150 @@
+<?php
+class ReverbApi {
+
+    const BASE_URL    = 'https://api.reverb.com/api';
+    const API_VERSION = '3.0';
+
+    private $token;
+
+    public function __construct($token) {
+        $this->token = $token;
+    }
+
+    // -------------------------------------------------------------------------
+    // Listings
+    // -------------------------------------------------------------------------
+
+    public function getListings($page = 1, $per_page = 50) {
+        return $this->request('GET', '/my/listings', [
+            'page'     => $page,
+            'per_page' => $per_page,
+        ]);
+    }
+
+    public function createListing(array $data) {
+        return $this->request('POST', '/listings', [], $data);
+    }
+
+    public function updateListing($listing_id, array $data) {
+        return $this->request('PUT', '/listings/' . urlencode($listing_id), [], $data);
+    }
+
+    public function endListing($listing_id) {
+        return $this->request('PUT', '/listings/' . urlencode($listing_id) . '/end');
+    }
+
+    public function uploadPhoto($listing_id, $image_url) {
+        return $this->request('POST', '/listings/' . urlencode($listing_id) . '/photos', [], [
+            'image_url' => $image_url,
+        ]);
+    }
+
+    // -------------------------------------------------------------------------
+    // Orders
+    // -------------------------------------------------------------------------
+
+    public function getOrders($page = 1, $per_page = 50) {
+        return $this->request('GET', '/my/orders', [
+            'page'     => $page,
+            'per_page' => $per_page,
+        ]);
+    }
+
+    public function getOrder($order_number) {
+        return $this->request('GET', '/my/orders/' . urlencode($order_number));
+    }
+
+    // -------------------------------------------------------------------------
+    // Metadata
+    // -------------------------------------------------------------------------
+
+    public function getCategories() {
+        return $this->request('GET', '/categories/flat');
+    }
+
+    public function getListingConditions() {
+        return $this->request('GET', '/listing_conditions');
+    }
+
+    // -------------------------------------------------------------------------
+    // Webhooks
+    // -------------------------------------------------------------------------
+
+    public function registerWebhook($url, $event) {
+        return $this->request('POST', '/webhooks', [], [
+            'url'   => $url,
+            'event' => $event,
+        ]);
+    }
+
+    public function getWebhooks() {
+        return $this->request('GET', '/webhooks');
+    }
+
+    public function deleteWebhook($webhook_id) {
+        return $this->request('DELETE', '/webhooks/' . (int)$webhook_id);
+    }
+
+    // -------------------------------------------------------------------------
+    // HTTP transport
+    // -------------------------------------------------------------------------
+
+    private function request($method, $endpoint, array $params = [], $body = null) {
+        $url = self::BASE_URL . $endpoint;
+
+        if (!empty($params)) {
+            $url .= '?' . http_build_query($params);
+        }
+
+        $headers = [
+            'Authorization: Bearer ' . $this->token,
+            'Accept-Version: ' . self::API_VERSION,
+            'Content-Type: application/hal+json',
+            'Accept: application/json',
+        ];
+
+        $ch = curl_init();
+        curl_setopt_array($ch, [
+            CURLOPT_URL            => $url,
+            CURLOPT_RETURNTRANSFER => true,
+            CURLOPT_HTTPHEADER     => $headers,
+            CURLOPT_TIMEOUT        => 30,
+            CURLOPT_SSL_VERIFYPEER => true,
+            CURLOPT_USERAGENT      => 'OpenCart-Reverb/1.0',
+        ]);
+
+        if (in_array($method, ['POST', 'PUT', 'DELETE'])) {
+            if ($method === 'POST') {
+                curl_setopt($ch, CURLOPT_POST, true);
+            } else {
+                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
+            }
+            if ($body !== null) {
+                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
+            }
+        }
+
+        $response   = curl_exec($ch);
+        $http_code  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+        $curl_error = curl_error($ch);
+        curl_close($ch);
+
+        if ($curl_error) {
+            throw new RuntimeException('cURL error: ' . $curl_error);
+        }
+
+        $decoded = json_decode($response, true);
+
+        if ($http_code >= 400) {
+            $message = 'HTTP ' . $http_code;
+            if (!empty($decoded['message'])) {
+                $message = $decoded['message'];
+            } elseif (!empty($decoded['errors'])) {
+                $message = implode(', ', (array)$decoded['errors']);
+            }
+            throw new RuntimeException('Reverb API error: ' . $message, $http_code);
+        }
+
+        return $decoded;
+    }
+}