# Offer Manager Frontend Redesign — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Redesign the offer detail page with a sticky left sidebar (category nav + coupon + dates), and build a full category page with banner + filter sidebar (price range, brands, attributes from OpenCart attribute system).

**Architecture:** 
- Model: 2 new methods on `ModelExtensionModuleOffers`
- Controller: `detail()`, `category()`, `search()` updates
- Views: full redesign of both theme `offers_info.twig` + `offers_category.twig` (new files)
- CSS: new sidebar + filter styles in both theme `offers.css`

**Tech Stack:** PHP, Twig, vanilla JS (XHR + pushState), CSS flexbox/grid, OpenCart `product_attribute` / `attribute` / `attribute_group` tables

**Prerequisite:** Plan A must run first (initial_limit removal, active state guard, language keys, global_badges).

---

## File Map

| File | Change |
|------|--------|
| `catalog/model/extension/module/offers.php` | Add `getOfferGroupFilters()`, `getFilteredGroupProducts()` |
| `catalog/controller/extension/module/offers.php` | `detail()`: remove initial_limit (done in Plan A), add shop_categories with hrefs; `category()`: pass filters; `search()`: accept filter params |
| `catalog/language/en-gb/extension/module/offers.php` | Add category page language keys |
| `catalog/view/theme/dreamer/template/extension/module/offers_info.twig` | Two-column layout with sticky sidebar |
| `catalog/view/theme/default/template/extension/module/offers_info.twig` | Same |
| `catalog/view/theme/dreamer/template/extension/module/offers_category.twig` | Banner + filter sidebar + product grid |
| `catalog/view/theme/default/template/extension/module/offers_category.twig` | Same |
| `catalog/view/theme/dreamer/stylesheet/offers.css` | Sidebar + filter + layout styles |
| `catalog/view/theme/default/stylesheet/offers.css` | Same |

---

## Task 1: Add model methods — getOfferGroupFilters() and getFilteredGroupProducts()

**Files:**
- Modify: `catalog/model/extension/module/offers.php`

- [ ] **Step 1: Add getOfferGroupFilters()**

Add this method after `productInGroup()` (after line ~180 in the current file):

```php
	public function getOfferGroupFilters($offer_group_id) {
		$offer_group_id = (int)$offer_group_id;
		$store_id       = (int)$this->config->get('config_store_id');

		$id_query = $this->db->query("
			SELECT DISTINCT ogp.product_id
			FROM `" . DB_PREFIX . "offer_group_product` ogp
			INNER JOIN `" . DB_PREFIX . "product` p ON (ogp.product_id = p.product_id)
			INNER JOIN `" . DB_PREFIX . "product_to_store` p2s ON (p.product_id = p2s.product_id)
			WHERE ogp.offer_group_id = '" . $offer_group_id . "'
				AND p.status = '1'
				AND p.date_available <= NOW()
				AND p.price > 0
				AND (p.subtract = '0' OR p.quantity > 0)
				AND p2s.store_id = '" . $store_id . "'");

		if (!$id_query->num_rows) {
			return array('price_min' => 0, 'price_max' => 0, 'manufacturers' => array(), 'attribute_groups' => array());
		}

		$product_ids_list = implode(',', array_map('intval', array_column($id_query->rows, 'product_id')));

		$price_query = $this->db->query("
			SELECT MIN(LEAST(p.price, COALESCE(ps.price, p.price))) AS price_min,
			       MAX(LEAST(p.price, COALESCE(ps.price, p.price))) AS price_max
			FROM `" . DB_PREFIX . "product` p
			LEFT JOIN `" . DB_PREFIX . "product_special` ps ON (
				p.product_id = ps.product_id
				AND ps.customer_group_id = '1'
				AND (ps.date_start = '0000-00-00' OR ps.date_start <= NOW())
				AND (ps.date_end = '0000-00-00' OR ps.date_end >= NOW())
			)
			WHERE p.product_id IN (" . $product_ids_list . ")");

		$mfr_query = $this->db->query("
			SELECT m.manufacturer_id, m.name, COUNT(p.product_id) AS total
			FROM `" . DB_PREFIX . "manufacturer` m
			INNER JOIN `" . DB_PREFIX . "product` p ON (p.manufacturer_id = m.manufacturer_id)
			WHERE p.product_id IN (" . $product_ids_list . ")
			GROUP BY m.manufacturer_id, m.name
			ORDER BY m.name ASC");

		$attr_query = $this->db->query("
			SELECT ag.attribute_group_id, agd.name AS group_name, ad.name AS attr_name, pa.text AS attr_value, COUNT(DISTINCT pa.product_id) AS total
			FROM `" . DB_PREFIX . "product_attribute` pa
			INNER JOIN `" . DB_PREFIX . "attribute` a ON (pa.attribute_id = a.attribute_id)
			INNER JOIN `" . DB_PREFIX . "attribute_description` ad ON (a.attribute_id = ad.attribute_id AND ad.language_id = '" . (int)$this->config->get('config_language_id') . "')
			INNER JOIN `" . DB_PREFIX . "attribute_group` ag ON (a.attribute_group_id = ag.attribute_group_id)
			INNER JOIN `" . DB_PREFIX . "attribute_group_description` agd ON (ag.attribute_group_id = agd.attribute_group_id AND agd.language_id = '" . (int)$this->config->get('config_language_id') . "')
			WHERE pa.product_id IN (" . $product_ids_list . ")
				AND pa.language_id = '" . (int)$this->config->get('config_language_id') . "'
				AND pa.text != ''
			GROUP BY ag.attribute_group_id, agd.name, ad.name, pa.text
			ORDER BY ag.sort_order ASC, ad.name ASC, pa.text ASC");

		$attribute_groups = array();
		foreach ($attr_query->rows as $row) {
			$gid = (int)$row['attribute_group_id'];
			if (!isset($attribute_groups[$gid])) {
				$attribute_groups[$gid] = array('attribute_group_id' => $gid, 'name' => $row['group_name'], 'values' => array());
			}
			$attribute_groups[$gid]['values'][] = array('text' => $row['attr_value'], 'count' => (int)$row['total']);
		}

		$attribute_groups_filtered = array();
		foreach ($attribute_groups as $ag) {
			if (count($ag['values']) > 1) {
				$attribute_groups_filtered[] = $ag;
			}
		}

		return array(
			'price_min'        => (float)($price_query->row['price_min'] ?? 0),
			'price_max'        => (float)($price_query->row['price_max'] ?? 0),
			'manufacturers'    => $mfr_query->rows,
			'attribute_groups' => array_values($attribute_groups_filtered)
		);
	}
```

- [ ] **Step 2: Add getFilteredGroupProducts()**

Add this method directly after `getOfferGroupFilters()`:

```php
	public function getFilteredGroupProducts($offer_group_id, $filters = array()) {
		$offer_group_id = (int)$offer_group_id;
		$store_id       = (int)$this->config->get('config_store_id');
		$lang_id        = (int)$this->config->get('config_language_id');

		$sql = "SELECT DISTINCT ogp.product_id, og.sort_order AS g_sort, ogp.sort_order AS p_sort
			FROM `" . DB_PREFIX . "offer_group_product` ogp
			INNER JOIN `" . DB_PREFIX . "offer_group` og ON (ogp.offer_group_id = og.offer_group_id)
			INNER JOIN `" . DB_PREFIX . "product` p ON (ogp.product_id = p.product_id)
			INNER JOIN `" . DB_PREFIX . "product_to_store` p2s ON (p.product_id = p2s.product_id)
			WHERE ogp.offer_group_id = '" . $offer_group_id . "'
				AND p.status = '1'
				AND p.date_available <= NOW()
				AND p.price > 0
				AND (p.subtract = '0' OR p.quantity > 0)
				AND p2s.store_id = '" . $store_id . "'";

		if (!empty($filters['price_min']) || !empty($filters['price_max'])) {
			$sql .= " AND EXISTS (
				SELECT 1 FROM `" . DB_PREFIX . "product` pp
				LEFT JOIN `" . DB_PREFIX . "product_special` ps ON (pp.product_id = ps.product_id AND ps.customer_group_id = '1' AND (ps.date_start = '0000-00-00' OR ps.date_start <= NOW()) AND (ps.date_end = '0000-00-00' OR ps.date_end >= NOW()))
				WHERE pp.product_id = ogp.product_id";
			$effective_price = "LEAST(pp.price, COALESCE(ps.price, pp.price))";
			if (!empty($filters['price_min'])) {
				$sql .= " AND " . $effective_price . " >= '" . (float)$filters['price_min'] . "'";
			}
			if (!empty($filters['price_max'])) {
				$sql .= " AND " . $effective_price . " <= '" . (float)$filters['price_max'] . "'";
			}
			$sql .= ")";
		}

		if (!empty($filters['manufacturer_id']) && is_array($filters['manufacturer_id'])) {
			$mfr_ids = implode(',', array_map('intval', $filters['manufacturer_id']));
			if ($mfr_ids) {
				$sql .= " AND p.manufacturer_id IN (" . $mfr_ids . ")";
			}
		}

		if (!empty($filters['attribute']) && is_array($filters['attribute'])) {
			foreach ($filters['attribute'] as $attr_name => $values) {
				if (!is_array($values) || empty($values)) { continue; }
				$attr_name_escaped = $this->db->escape($attr_name);
				$value_clauses = array();
				foreach ($values as $v) {
					$value_clauses[] = "pa.text = '" . $this->db->escape($v) . "'";
				}
				$sql .= " AND EXISTS (
					SELECT 1 FROM `" . DB_PREFIX . "product_attribute` pa
					INNER JOIN `" . DB_PREFIX . "attribute_description` ad ON (pa.attribute_id = ad.attribute_id AND ad.language_id = '" . $lang_id . "')
					WHERE pa.product_id = ogp.product_id
						AND pa.language_id = '" . $lang_id . "'
						AND ad.name = '" . $attr_name_escaped . "'
						AND (" . implode(' OR ', $value_clauses) . ")
				)";
			}
		}

		$sql .= " ORDER BY og.sort_order ASC, ogp.sort_order ASC, ogp.product_id ASC";

		$query = $this->db->query($sql);
		return array_column($query->rows, 'product_id');
	}
```

- [ ] **Step 3: Verify methods added**

Run: `grep -n "getOfferGroupFilters\|getFilteredGroupProducts" /var/www/skyland/src/catalog/model/extension/module/offers.php`
Expected: 2 method definitions found.

- [ ] **Step 4: Commit**

```bash
git add catalog/model/extension/module/offers.php
git commit -m "feat(model): add getOfferGroupFilters() and getFilteredGroupProducts() to offers model"
```

---

## Task 2: Update controller — category() and search()

**Files:**
- Modify: `catalog/controller/extension/module/offers.php`

- [ ] **Step 1: Update category() to pass filters data to view**

Find `category()` method (line ~177). After the `$current_category` check (after line ~207), before setting up `$data`:

Add filter extraction and call to `getOfferGroupFilters`:
```php
		$filters = array(
			'price_min'      => isset($this->request->get['price_min']) ? (float)$this->request->get['price_min'] : '',
			'price_max'      => isset($this->request->get['price_max']) ? (float)$this->request->get['price_max'] : '',
			'manufacturer_id' => isset($this->request->get['manufacturer_id']) && is_array($this->request->get['manufacturer_id']) ? $this->request->get['manufacturer_id'] : array(),
			'attribute'      => isset($this->request->get['attribute']) && is_array($this->request->get['attribute']) ? $this->request->get['attribute'] : array()
		);

		$group_filters = $this->model_extension_module_offers->getOfferGroupFilters($category_id);

		if (!empty($filters['price_min']) || !empty($filters['price_max']) || !empty($filters['manufacturer_id']) || !empty($filters['attribute'])) {
			$filtered_ids = $this->model_extension_module_offers->getFilteredGroupProducts($category_id, $filters);
			$products_data = $this->buildOfferProductsFromIds($filtered_ids);
		} else {
			$products_data = $this->buildOfferProducts((int)$offer['offer_id'], '', $category_id);
		}
```

Replace the existing line:
```php
		$data['products'] = $this->buildOfferProducts((int)$offer['offer_id'], '', $category_id);
```
with:
```php
		$data['products'] = $products_data;
```

Add filter and offer data to `$data`:
```php
		$data['filters']       = $filters;
		$data['group_filters'] = $group_filters;
		$data['offer_title']   = $offer['title'];
		$data['offer_slug']    = $offer['slug'];
		$data['offer']         = array(
			'title'      => $offer['title'],
			'coupon_code' => !empty($offer['coupon_code']) ? $offer['coupon_code'] : '',
			'date_start' => $offer['date_start'],
			'date_end'   => $offer['date_end'],
			'state'      => $this->getOfferState($offer['date_start'], $offer['date_end']),
			'countdown_to' => $this->formatCountdownDate($offer['date_end']),
		);
		$data['category_name'] = $current_category['name'];
		$data['filter_url']    = $this->url->link('extension/module/offers/search', 'offer_slug=' . rawurlencode($offer['slug']) . '&category_id=' . $category_id);
```

Also add these language keys to `category()`:
```php
		$data['text_ends_in']          = $this->language->get('text_ends_in');
		$data['text_days']             = $this->language->get('text_days');
		$data['text_hours']            = $this->language->get('text_hours');
		$data['text_minutes']          = $this->language->get('text_minutes');
		$data['text_seconds']          = $this->language->get('text_seconds');
		$data['text_no_products']      = $this->language->get('text_no_products');
		$data['text_save']             = $this->language->get('text_save');
		$data['text_searching']        = $this->language->get('text_searching');
		$data['text_products_found']   = $this->language->get('text_products_found');
		$data['text_price_range']      = $this->language->get('text_price_range');
		$data['text_brand']            = $this->language->get('text_brand');
		$data['text_apply_filters']    = $this->language->get('text_apply_filters');
		$data['text_clear_all']        = $this->language->get('text_clear_all');
		$data['text_sort_default']     = $this->language->get('text_sort_default');
		$data['text_sort_price_asc']   = $this->language->get('text_sort_price_asc');
		$data['text_sort_price_desc']  = $this->language->get('text_sort_price_desc');
		$data['text_sort_name_asc']    = $this->language->get('text_sort_name_asc');
		$data['text_showing']          = $this->language->get('text_showing');
```

- [ ] **Step 2: Add helper buildOfferProductsFromIds()**

In the controller, add a new protected method:

```php
	protected function buildOfferProductsFromIds($product_ids) {
		$products_data = $this->model_catalog_product->fetchProductsByIds($product_ids);
		$products = array();

		foreach ($product_ids as $product_id) {
			if (empty($products_data[$product_id])) { continue; }
			$product = $products_data[$product_id];

			if ((float)$product['price'] <= 0 || ((int)$product['subtract'] && (int)$product['quantity'] <= 0)) {
				continue;
			}

			$thumb = $product['image']
				? $this->model_tool_image->resize($product['image'], $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_width'), $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_height'))
				: $this->model_tool_image->resize('placeholder.png', $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_width'), $this->config->get('theme_' . $this->config->get('config_theme') . '_image_product_height'));

			$price = false;
			if ($this->customer->isLogged() || !$this->config->get('config_customer_price')) {
				$price = $this->currency->format($this->tax->calculate($product['price'], $product['tax_class_id'], $this->config->get('config_tax')), $this->session->data['currency']);
			}

			$special = false;
			$save    = false;

			if ($product['special'] !== null && (float)$product['special'] >= 0) {
				$special = $this->currency->format($this->tax->calculate($product['special'], $product['tax_class_id'], $this->config->get('config_tax')), $this->session->data['currency']);

				if ((float)$product['price'] > (float)$product['special']) {
					$save = $this->currency->format($this->tax->calculate((float)$product['price'] - (float)$product['special'], $product['tax_class_id'], $this->config->get('config_tax')), $this->session->data['currency']);
				}
			}

			$products[] = array(
				'product_id'        => (int)$product['product_id'],
				'name'              => $product['name'],
				'thumb'             => $thumb,
				'price'             => $price,
				'special'           => $special,
				'save'              => $save,
				'minimum'           => $product['minimum'] > 0 ? $product['minimum'] : 1,
				'href'              => $this->url->link('product/product', 'product_id=' . (int)$product['product_id']),
				'quantity'          => (int)$product['quantity'],
				'subtract'          => (int)$product['subtract'],
				'stock_status_name' => (string)$product['stock_status'],
				'global_badges'     => method_exists($this->model_catalog_product, 'getProductBadges') ? $this->model_catalog_product->getProductBadges((int)$product['product_id']) : array(),
			);
		}

		return $products;
	}
```

- [ ] **Step 3: Update search() to accept filter params**

Find `search()` method (line ~226). Inside the `if ($offer && getOfferState === 'active')` block, update to:
```php
		if ($offer && $this->getOfferState($offer['date_start'], $offer['date_end']) === 'active') {
			$filter_name  = isset($this->request->get['filter_name']) ? trim($this->request->get['filter_name']) : '';
			$category_id  = isset($this->request->get['category_id']) ? (int)$this->request->get['category_id'] : 0;
			$price_min    = isset($this->request->get['price_min']) && $this->request->get['price_min'] !== '' ? (float)$this->request->get['price_min'] : '';
			$price_max    = isset($this->request->get['price_max']) && $this->request->get['price_max'] !== '' ? (float)$this->request->get['price_max'] : '';
			$manufacturer_id = isset($this->request->get['manufacturer_id']) && is_array($this->request->get['manufacturer_id']) ? $this->request->get['manufacturer_id'] : array();
			$attribute    = isset($this->request->get['attribute']) && is_array($this->request->get['attribute']) ? $this->request->get['attribute'] : array();

			if ($category_id && ($price_min !== '' || $price_max !== '' || $manufacturer_id || $attribute)) {
				$filters = array(
					'price_min'      => $price_min,
					'price_max'      => $price_max,
					'manufacturer_id' => $manufacturer_id,
					'attribute'      => $attribute
				);
				$filtered_ids = $this->model_extension_module_offers->getFilteredGroupProducts($category_id, $filters);
				$products = $this->buildOfferProductsFromIds($filtered_ids);
				if ($filter_name !== '') {
					$filter_name_lower = utf8_strtolower(trim($filter_name));
					$products = array_values(array_filter($products, function($p) use ($filter_name_lower) {
						return strpos(utf8_strtolower($p['name']), $filter_name_lower) !== false;
					}));
				}
			} else {
				$products = $this->buildOfferProducts((int)$offer['offer_id'], $filter_name, $category_id);
			}

			$json['products'] = $products;
			$json['total']    = count($products);
		}
```

- [ ] **Step 4: Verify controller changes**

Run: `grep -n "getFilteredGroupProducts\|buildOfferProductsFromIds\|price_min\|manufacturer_id" /var/www/skyland/src/catalog/controller/extension/module/offers.php | head -20`
Expected: multiple matches confirming all 3 update areas.

- [ ] **Step 5: Commit**

```bash
git add catalog/controller/extension/module/offers.php
git commit -m "feat(controller): update category() and search() to support filter params; add buildOfferProductsFromIds()"
```

---

## Task 3: Add category page language keys

**Files:**
- Modify: `catalog/language/en-gb/extension/module/offers.php`

- [ ] **Step 1: Add all new keys**

Append to `catalog/language/en-gb/extension/module/offers.php`:
```php
$_['text_price_range']     = 'Price Range';
$_['text_brand']           = 'Brand';
$_['text_apply_filters']   = 'Apply Filters';
$_['text_clear_all']       = 'Clear All';
$_['text_sort_default']    = 'Sort: Default';
$_['text_sort_price_asc']  = 'Price: Low to High';
$_['text_sort_price_desc'] = 'Price: High to Low';
$_['text_sort_name_asc']   = 'Name: A to Z';
$_['text_showing']         = 'Showing {{shown}} of {{total}} products';
```

- [ ] **Step 2: Commit**

```bash
git add catalog/language/en-gb/extension/module/offers.php
git commit -m "feat(lang): add category page filter and sort language keys"
```

---

## Task 4: Redesign offers_info.twig — sticky sidebar layout

**Apply to both dreamer and default themes.**

**Files:**
- Modify: `catalog/view/theme/dreamer/template/extension/module/offers_info.twig`
- Modify: `catalog/view/theme/default/template/extension/module/offers_info.twig`

**Layout change:** Wrap the product section in a two-column flex layout. The left column (220px) is the sticky sidebar. The right column is the product grid. The sidebar replaces the horizontal `offer-category-nav`.

- [ ] **Step 1: Remove the horizontal category nav block**

Find and delete the entire block:
```twig
  {% if offer.state == 'active' and shop_categories|length > 1 %}
  <nav class="offer-category-nav container" aria-label="{{ text_shop_by_category }}">
    <div class="offer-category-nav__title">{{ text_shop_by_category }}</div>
    <div class="offer-category-nav__list">
      {% for category in shop_categories %}
      <a href="#offer-products-grid" class="offer-category-nav__item{% if loop.first %} active{% endif %}" data-category-id="{{ category.category_id }}" data-href="{{ category.href }}">
        <span>{{ category.name }}</span>
        <small>{{ category.total }}</small>
      </a>
      {% endfor %}
    </div>
  </nav>
  {% endif %}
```

- [ ] **Step 2: Replace the products section with two-column layout**

Find the products section:
```twig
  {# ── Products ── #}
  {% if offer.state == 'active' and products %}
  <section class="products-section container">
```

Replace everything from this line through `{% elseif offer.state == 'active' %}` with the following two-column layout:

```twig
  {# ── Two-column: sidebar + products ── #}
  {% if offer.state == 'active' %}
  <div class="offer-layout container">

    {# ── Sidebar ── #}
    {% if shop_categories|length > 1 %}
    <aside class="offer-sidebar">
      <div class="offer-sidebar__inner">
        <div class="offer-sidebar__heading">{{ text_shop_by_category }}</div>
        <nav class="offer-sidebar__cats">
          {% for category in shop_categories %}
          {% if category.href %}
          <a href="{{ category.href }}" class="offer-sidebar__cat-item">
            <span class="offer-sidebar__cat-name">{{ category.name }}</span>
            <span class="offer-sidebar__cat-count">{{ category.total }}</span>
          </a>
          {% else %}
          <span class="offer-sidebar__cat-item offer-sidebar__cat-item--all active" data-category-id="0" data-href="">
            <span class="offer-sidebar__cat-name">{{ category.name }}</span>
            <span class="offer-sidebar__cat-count">{{ category.total }}</span>
          </span>
          {% endif %}
          {% endfor %}
        </nav>
        {% if offer.coupon_code %}
        <div class="offer-sidebar__coupon">
          <div class="offer-sidebar__coupon-label">{{ text_use_code }}</div>
          <button type="button" class="offer-sidebar__coupon-btn" data-copy="{{ offer.coupon_code }}">
            <span>{{ offer.coupon_code }}</span>
          </button>
        </div>
        {% endif %}
        <div class="offer-sidebar__dates">
          <span class="offer-sidebar__date-range">{{ offer.date_start|date('d M Y') }} – {{ offer.date_end|date('d M Y') }}</span>
        </div>
      </div>
    </aside>
    {% endif %}

    {# ── Main: product grid ── #}
    <section class="offer-main products-section">
      {% if products %}
      <div class="products-section__head">
        <h2 class="products-title">{{ text_products_in_offer }}</h2>
        <div class="offer-product-search">
          <label class="offer-product-search__control" for="offer-product-search">
            <span class="sr-only">{{ text_search_products }}</span>
            <i class="fa fa-search" aria-hidden="true"></i>
            <input type="search" id="offer-product-search" value="" placeholder="{{ text_search_placeholder }}" data-url="{{ product_search_url }}" autocomplete="off" />
            <button type="button" class="offer-product-search__clear" id="offer-product-search-clear" aria-label="Clear search" hidden>&times;</button>
          </label>
          <div class="offer-product-search__status" id="offer-product-search-status"></div>
        </div>
      </div>
      <div class="offer-products-grid" id="offer-products-grid">
        {% for product in products %}
        <div class="offer-product-layout">
          <div class="product-thumb offer-product-thumb">
            <div class="image">
              {% if product.global_badges %}
              <div class="product-badge-stack">{% for badge in product.global_badges %}<span class="product-badge" style="background-color:{{ badge.background_color }};color:{{ badge.text_color }};">{{ badge.label }}</span>{% endfor %}</div>
              {% endif %}
              {% if product.save %}<span class="badge-save">{{ text_save }} {{ product.save }}</span>{% endif %}
              <a href="{{ product.href }}"><img src="{{ product.thumb }}" alt="{{ product.name }}" title="{{ product.name }}" loading="lazy" /></a>
            </div>
            <div class="caption">
              <h4><a href="{{ product.href }}">{{ product.name }}</a></h4>
              <p class="price">
                {% if product.special %}<span class="price-new">{{ product.special }}</span><span class="price-old">{{ product.price }}</span>
                {% elseif product.price %}{{ product.price }}{% endif %}
              </p>
            </div>
          </div>
        </div>
        {% endfor %}
      </div>
      <div class="offer-message offer-message--search-empty" id="offer-products-empty" hidden><h2>{{ text_no_products }}</h2></div>
      {% else %}
      <div class="offer-message" id="offer-products-empty"><h2>{{ text_no_products }}</h2></div>
      {% endif %}
    </section>

  </div>
  {% endif %}
```

- [ ] **Step 3: Update JS — remove category nav click handler, keep search as-is**

In the JS block, the old category nav click handler references `.offer-category-nav__item`. Replace with:

```javascript
  /* Sidebar "All Products" item — no navigation needed, search resets */
  document.querySelectorAll('.offer-sidebar__cat-item--all').forEach(function(item) {
    item.addEventListener('click', function() {
      activeCategoryId = 0;
      activeCategoryHref = '';
      document.querySelectorAll('.offer-sidebar__cat-item').forEach(function(i) { i.classList.remove('active'); });
      item.classList.add('active');
      runSearch();
    });
  });
```

Delete the old `categoryLinks` variable and forEach listener that referenced `.offer-category-nav__item`.

Also delete `activeCategoryHref` variable and any remaining references to `moreWrap`/`loadMore` (Plan A already removed these, but verify).

- [ ] **Step 4: Add responsive pill nav for < 992px (in CSS, not JS)**

The sidebar hides on mobile via CSS; a horizontal pill nav replaces it. No JS needed — add CSS media query in Task 6.

- [ ] **Step 5: Apply same changes to default/offers_info.twig**

- [ ] **Step 6: Commit**

```bash
git add catalog/view/theme/dreamer/template/extension/module/offers_info.twig
git add catalog/view/theme/default/template/extension/module/offers_info.twig
git commit -m "feat(frontend): redesign offer detail page with sticky sidebar layout"
```

---

## Task 5: Redesign offers_category.twig — banner + filter sidebar + product grid

**Apply to both dreamer and default themes.**

**Files:**
- Modify: `catalog/view/theme/dreamer/template/extension/module/offers_category.twig`
- Modify: `catalog/view/theme/default/template/extension/module/offers_category.twig`

The category page is a full redesign. Replace the entire file content.

- [ ] **Step 1: Write new dreamer/offers_category.twig**

Replace the entire file with:

```twig
{{ header }}
<div id="offer-category-page" class="offers-page">

  {# ── Breadcrumb ── #}
  <div class="container offer-cat-breadcrumb">
    <a href="{{ back }}">&#8592; {{ offer.title }}</a>
    &nbsp;/&nbsp; <strong>{{ category_name }}</strong>
  </div>

  {# ── Banner strip ── #}
  <div class="container">
    <div class="offer-cat-banner">
      <div class="offer-cat-banner__left">
        <div class="offer-cat-banner__title">{{ category_name }} &mdash; {{ offer.title }}</div>
        <div class="offer-cat-banner__count">{{ products|length }} products on sale</div>
      </div>
      <div class="offer-cat-banner__right">
        {% if offer.countdown_to and offer.state == 'active' %}
        <span class="offer-header__pill offer-cat-banner__countdown" data-target="{{ offer.countdown_to }}">
          {{ text_ends_in }} <span class="ocp-text">--D --H</span>
        </span>
        {% endif %}
        {% if offer.coupon_code %}
        <div class="offer-cat-banner__coupon">
          <span class="offer-cat-banner__coupon-label">Use code</span>
          <button type="button" class="coupon-code" data-copy="{{ offer.coupon_code }}"><span>{{ offer.coupon_code }}</span></button>
        </div>
        {% endif %}
      </div>
    </div>
  </div>

  {# ── Two-column: filter sidebar + products ── #}
  <div class="offer-layout container">

    {# ── Filter sidebar ── #}
    <aside class="offer-sidebar offer-filter-sidebar">
      <div class="offer-sidebar__inner">

        {# Price Range #}
        <div class="offer-filter-section">
          <div class="offer-filter-section__title">{{ text_price_range }}</div>
          <div class="offer-filter-price-row">
            <input type="number" id="filter-price-min" class="form-control input-sm" placeholder="Min" value="{{ filters.price_min ?: '' }}" min="0" />
            <span>–</span>
            <input type="number" id="filter-price-max" class="form-control input-sm" placeholder="Max" value="{{ filters.price_max ?: '' }}" min="0" />
          </div>
        </div>

        {# Brands #}
        {% if group_filters.manufacturers %}
        <div class="offer-filter-section">
          <div class="offer-filter-section__title">{{ text_brand }}</div>
          {% for mfr in group_filters.manufacturers %}
          <label class="offer-filter-checkbox">
            <input type="checkbox" name="manufacturer_id[]" value="{{ mfr.manufacturer_id }}"{% if mfr.manufacturer_id in filters.manufacturer_id %} checked{% endif %} />
            {{ mfr.name }} <span class="offer-filter-count">({{ mfr.total }})</span>
          </label>
          {% endfor %}
        </div>
        {% endif %}

        {# Attribute Groups #}
        {% for ag in group_filters.attribute_groups %}
        <div class="offer-filter-section">
          <div class="offer-filter-section__title">{{ ag.name }}</div>
          {% for val in ag.values %}
          <label class="offer-filter-checkbox">
            <input type="checkbox" name="attribute[{{ ag.name }}][]" value="{{ val.text }}"{% if filters.attribute[ag.name] is defined and val.text in filters.attribute[ag.name] %} checked{% endif %} />
            {{ val.text }} <span class="offer-filter-count">({{ val.count }})</span>
          </label>
          {% endfor %}
        </div>
        {% endfor %}

        <button type="button" id="btn-apply-filters" class="btn btn-primary btn-block" style="margin-top:10px;">{{ text_apply_filters }}</button>
        <a href="{{ back }}" class="offer-filter-clear-link" style="display:block;text-align:center;margin-top:6px;font-size:12px;">&#8592; Back to offer</a>
        <a href="#" id="btn-clear-filters" class="offer-filter-clear-link" style="display:block;text-align:center;margin-top:4px;font-size:12px;">{{ text_clear_all }}</a>

      </div>
    </aside>

    {# ── Product area ── #}
    <section class="offer-main products-section">
      <div class="offer-cat-controls">
        <div class="offer-cat-count" id="offer-cat-count">
          {{ text_showing|replace({'{{shown}}': products|length, '{{total}}': products|length}) }}
        </div>
        <select id="offer-sort" class="form-control input-sm offer-cat-sort">
          <option value="default">{{ text_sort_default }}</option>
          <option value="price_asc">{{ text_sort_price_asc }}</option>
          <option value="price_desc">{{ text_sort_price_desc }}</option>
          <option value="name_asc">{{ text_sort_name_asc }}</option>
        </select>
      </div>

      <div class="offer-products-grid" id="offer-products-grid">
        {% for product in products %}
        <div class="offer-product-layout">
          <div class="product-thumb offer-product-thumb">
            <div class="image">
              {% if product.global_badges %}
              <div class="product-badge-stack">{% for badge in product.global_badges %}<span class="product-badge" style="background-color:{{ badge.background_color }};color:{{ badge.text_color }};">{{ badge.label }}</span>{% endfor %}</div>
              {% endif %}
              {% if product.save %}<span class="badge-save">{{ text_save }} {{ product.save }}</span>{% endif %}
              <a href="{{ product.href }}"><img src="{{ product.thumb }}" alt="{{ product.name }}" title="{{ product.name }}" loading="lazy" /></a>
            </div>
            <div class="caption">
              <h4><a href="{{ product.href }}">{{ product.name }}</a></h4>
              <p class="price">
                {% if product.special %}<span class="price-new">{{ product.special }}</span><span class="price-old">{{ product.price }}</span>
                {% elseif product.price %}{{ product.price }}{% endif %}
              </p>
            </div>
          </div>
        </div>
        {% endfor %}
      </div>
      <div class="offer-message offer-message--search-empty" id="offer-products-empty" hidden><h2>{{ text_no_products }}</h2></div>
    </section>

  </div>

  {{ content_bottom }}
</div>

<script type="text/javascript"><!--
(function() {
  var grid     = document.getElementById('offer-products-grid');
  var empty    = document.getElementById('offer-products-empty');
  var countEl  = document.getElementById('offer-cat-count');
  var sortSel  = document.getElementById('offer-sort');
  var filterUrl = {{ filter_url|json_encode|raw }};
  var textSave  = {{ text_save|json_encode|raw }};
  var textNoProducts = {{ text_no_products|json_encode|raw }};
  var textSearching  = {{ text_searching|json_encode|raw }};
  var textProductsFound = {{ text_products_found|json_encode|raw }};
  var textShowing = {{ text_showing|json_encode|raw }};
  var activeRequest = null;

  function escapeHtml(v) {
    return String(v || '').replace(/[&<>'"]/g, function(c) {
      return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[c];
    });
  }

  function renderProduct(product) {
    var price = product.special
      ? '<span class="price-new">' + escapeHtml(product.special) + '</span> <span class="price-old">' + escapeHtml(product.price) + '</span>'
      : escapeHtml(product.price || '');
    var badges = '';
    if (product.global_badges && product.global_badges.length) {
      badges = '<div class="product-badge-stack">' + product.global_badges.map(function(b) {
        return '<span class="product-badge" style="background-color:' + escapeHtml(b.background_color) + ';color:' + escapeHtml(b.text_color) + ';">' + escapeHtml(b.label) + '</span>';
      }).join('') + '</div>';
    }
    return '<div class="offer-product-layout"><div class="product-thumb offer-product-thumb"><div class="image">' +
      badges +
      (product.save ? '<span class="badge-save">' + escapeHtml(textSave + ' ' + product.save) + '</span>' : '') +
      '<a href="' + escapeHtml(product.href) + '"><img src="' + escapeHtml(product.thumb) + '" alt="' + escapeHtml(product.name) + '" loading="lazy" /></a>' +
      '</div><div class="caption"><h4><a href="' + escapeHtml(product.href) + '">' + escapeHtml(product.name) + '</a></h4>' +
      '<p class="price">' + price + '</p></div></div></div>';
  }

  function sortProducts(products, sortKey) {
    if (sortKey === 'price_asc') {
      return products.slice().sort(function(a, b) {
        return parseFloat((a.special || a.price || '0').replace(/[^\d.]/g, '')) - parseFloat((b.special || b.price || '0').replace(/[^\d.]/g, ''));
      });
    } else if (sortKey === 'price_desc') {
      return products.slice().sort(function(a, b) {
        return parseFloat((b.special || b.price || '0').replace(/[^\d.]/g, '')) - parseFloat((a.special || a.price || '0').replace(/[^\d.]/g, ''));
      });
    } else if (sortKey === 'name_asc') {
      return products.slice().sort(function(a, b) { return a.name.localeCompare(b.name); });
    }
    return products;
  }

  var allProducts = [];

  function renderProducts(products) {
    allProducts = products;
    var sorted = sortProducts(products, sortSel ? sortSel.value : 'default');
    if (!sorted.length) {
      grid.innerHTML = '';
      if (empty) { empty.hidden = false; }
      if (countEl) { countEl.textContent = textShowing.replace('{{shown}}', 0).replace('{{total}}', 0); }
      return;
    }
    grid.innerHTML = sorted.map(renderProduct).join('');
    if (empty) { empty.hidden = true; }
    if (countEl) { countEl.textContent = textShowing.replace('{{shown}}', sorted.length).replace('{{total}}', sorted.length); }
  }

  if (sortSel) {
    sortSel.addEventListener('change', function() {
      renderProducts(allProducts);
    });
  }

  function buildFilterParams() {
    var params = [];
    var priceMin = document.getElementById('filter-price-min');
    var priceMax = document.getElementById('filter-price-max');
    if (priceMin && priceMin.value) { params.push('price_min=' + encodeURIComponent(priceMin.value)); }
    if (priceMax && priceMax.value) { params.push('price_max=' + encodeURIComponent(priceMax.value)); }
    document.querySelectorAll('[name="manufacturer_id[]"]:checked').forEach(function(cb) {
      params.push('manufacturer_id[]=' + encodeURIComponent(cb.value));
    });
    document.querySelectorAll('[name^="attribute["]:checked').forEach(function(cb) {
      params.push(encodeURIComponent(cb.name) + '=' + encodeURIComponent(cb.value));
    });
    return params.join('&');
  }

  function applyFilters() {
    var paramStr = buildFilterParams();
    var url = filterUrl + (filterUrl.indexOf('?') === -1 ? '?' : '&') + paramStr;

    if (activeRequest) { activeRequest.abort(); }
    activeRequest = new XMLHttpRequest();
    activeRequest.open('GET', url, true);
    activeRequest.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
    activeRequest.onreadystatechange = function() {
      if (activeRequest.readyState !== 4) { return; }
      if (activeRequest.status >= 200 && activeRequest.status < 300) {
        try {
          var payload = JSON.parse(activeRequest.responseText);
          renderProducts(payload.products || []);
        } catch (e) {
          renderProducts([]);
        }
      } else {
        if (countEl) { countEl.textContent = ''; }
        renderProducts([]);
      }
    };
    activeRequest.send();

    history.pushState(null, '', '?' + window.location.search.replace(/^\?/, '').split('&').filter(function(p) {
      return p.startsWith('route=') || p.startsWith('offer_slug=') || p.startsWith('category_id=');
    }).concat(paramStr.split('&').filter(Boolean)).join('&'));
  }

  var applyBtn = document.getElementById('btn-apply-filters');
  if (applyBtn) { applyBtn.addEventListener('click', applyFilters); }

  var clearBtn = document.getElementById('btn-clear-filters');
  if (clearBtn) {
    clearBtn.addEventListener('click', function(e) {
      e.preventDefault();
      document.querySelectorAll('#offer-category-page [type="checkbox"]').forEach(function(cb) { cb.checked = false; });
      var pmin = document.getElementById('filter-price-min');
      var pmax = document.getElementById('filter-price-max');
      if (pmin) { pmin.value = ''; }
      if (pmax) { pmax.value = ''; }
      applyFilters();
    });
  }

  /* Init server-rendered product list */
  var serverProducts = [];
  document.querySelectorAll('#offer-products-grid .offer-product-layout').forEach(function(el) {
    /* Keep DOM as-is on first load — only AJAX replaces it */
  });
  if (countEl) {
    var initial = grid.querySelectorAll('.offer-product-layout').length;
    countEl.textContent = textShowing.replace('{{shown}}', initial).replace('{{total}}', initial);
  }

  /* Banner countdown */
  var pill = document.querySelector('.offer-cat-banner__countdown');
  if (pill) {
    var raw = pill.getAttribute('data-target');
    var target = raw ? new Date(raw.replace(' ', 'T')).getTime() : 0;
    var textEl = pill.querySelector('.ocp-text');
    function pad(n) { return n < 10 ? '0' + n : String(n); }
    if (target && textEl) {
      (function tick() {
        var diff = target - Date.now();
        if (isNaN(diff) || diff <= 0) { pill.style.display = 'none'; return; }
        textEl.textContent = pad(Math.floor(diff / 86400000)) + 'D ' + pad(Math.floor((diff % 86400000) / 3600000)) + 'H';
        setTimeout(tick, 60000);
      })();
    }
  }

  /* Coupon copy */
  document.querySelectorAll('[data-copy]').forEach(function(node) {
    node.addEventListener('click', function() {
      var code = node.getAttribute('data-copy');
      if (!code) { return; }
      function fallback() {
        var ta = document.createElement('textarea');
        ta.value = code; ta.style.cssText = 'position:fixed;opacity:0;'; document.body.appendChild(ta); ta.select();
        try { document.execCommand('copy'); } catch(e) {} document.body.removeChild(ta);
      }
      if (navigator.clipboard) { navigator.clipboard.writeText(code).catch(fallback); } else { fallback(); }
    });
  });
})();
//--></script>
{{ footer }}
```

- [ ] **Step 2: Copy equivalent structure to default/offers_category.twig**

The default theme file has the same structure. Replace its content with the same template.

- [ ] **Step 3: Commit**

```bash
git add catalog/view/theme/dreamer/template/extension/module/offers_category.twig
git add catalog/view/theme/default/template/extension/module/offers_category.twig
git commit -m "feat(frontend): redesign category page with banner, filter sidebar, product grid"
```

---

## Task 6: Add sidebar + filter CSS to both theme stylesheets

**Files:**
- Modify: `catalog/view/theme/dreamer/stylesheet/offers.css`
- Modify: `catalog/view/theme/default/stylesheet/offers.css`

- [ ] **Step 1: Append sidebar and layout styles to dreamer/offers.css**

```css
/* ── Two-column layout ── */
.offer-layout {
  display: flex;
  gap: 24px;
  align-items: flex-start;
  padding-top: 20px;
  padding-bottom: 40px;
}

/* ── Sidebar ── */
.offer-sidebar {
  width: 220px;
  flex-shrink: 0;
  position: sticky;
  top: 16px;
}
.offer-sidebar__inner {
  background: #fff;
  border: 1px solid #e2e6ef;
  border-radius: 6px;
  padding: 14px;
}
.offer-sidebar__heading {
  font-size: 13px;
  font-weight: 700;
  color: #333;
  text-transform: uppercase;
  letter-spacing: .04em;
  margin-bottom: 10px;
  padding-bottom: 6px;
  border-bottom: 2px solid #e8ecf8;
}
.offer-sidebar__cats { display: flex; flex-direction: column; gap: 2px; margin-bottom: 16px; }
.offer-sidebar__cat-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 8px;
  border-radius: 4px;
  font-size: 13px;
  color: #333;
  text-decoration: none;
  cursor: pointer;
  transition: background .15s;
}
.offer-sidebar__cat-item:hover,
.offer-sidebar__cat-item.active {
  background: #edf0ff;
  color: #3749bb;
}
.offer-sidebar__cat-count {
  font-size: 11px;
  color: #888;
  background: #f0f2f8;
  border-radius: 10px;
  padding: 1px 6px;
}
.offer-sidebar__coupon {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid #eee;
}
.offer-sidebar__coupon-label { font-size: 11px; color: #888; margin-bottom: 4px; }
.offer-sidebar__coupon-btn {
  width: 100%;
  border: 1px dashed #3749bb;
  border-radius: 4px;
  background: #f8f9ff;
  color: #3749bb;
  padding: 6px;
  font-size: 13px;
  font-family: monospace;
  font-weight: 700;
  letter-spacing: .08em;
  cursor: pointer;
  text-align: center;
}
.offer-sidebar__dates {
  margin-top: 12px;
  font-size: 11px;
  color: #888;
}

/* ── Main content area ── */
.offer-main { flex: 1; min-width: 0; }

/* ── Filter sidebar (category page) ── */
.offer-filter-sidebar .offer-sidebar__inner { padding: 12px; }
.offer-filter-section { margin-bottom: 16px; }
.offer-filter-section__title {
  font-size: 12px;
  font-weight: 700;
  color: #444;
  text-transform: uppercase;
  letter-spacing: .04em;
  margin-bottom: 6px;
}
.offer-filter-price-row {
  display: flex;
  align-items: center;
  gap: 6px;
}
.offer-filter-price-row input { width: 80px; }
.offer-filter-checkbox {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: normal;
  cursor: pointer;
  margin-bottom: 4px;
}
.offer-filter-count { color: #999; }
.offer-filter-clear-link { color: #888; text-decoration: none; }
.offer-filter-clear-link:hover { text-decoration: underline; }

/* ── Category page banner ── */
.offer-cat-breadcrumb { font-size: 12px; color: #888; padding-top: 12px; padding-bottom: 8px; }
.offer-cat-breadcrumb a { color: #3749bb; text-decoration: none; }
.offer-cat-banner {
  background: linear-gradient(90deg, #3749bb, #6c63ff);
  color: #fff;
  border-radius: 6px;
  padding: 14px 20px;
  margin-bottom: 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}
.offer-cat-banner__title { font-size: 16px; font-weight: 700; margin-bottom: 2px; }
.offer-cat-banner__count { font-size: 12px; opacity: .8; }
.offer-cat-banner__right { display: flex; align-items: center; gap: 12px; flex-shrink: 0; }
.offer-cat-banner__countdown { background: rgba(255,255,255,.15); border-radius: 20px; padding: 4px 10px; font-size: 13px; font-weight: 700; }
.offer-cat-banner__coupon { text-align: center; }
.offer-cat-banner__coupon-label { font-size: 10px; opacity: .75; margin-bottom: 2px; }
.offer-cat-banner .coupon-code {
  background: rgba(255,255,255,.15);
  border: 1px solid rgba(255,255,255,.4);
  border-radius: 4px;
  color: #fff;
  font-family: monospace;
  font-weight: 700;
  font-size: 13px;
  letter-spacing: .1em;
  padding: 4px 10px;
  cursor: pointer;
}

/* ── Category controls bar ── */
.offer-cat-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 14px;
  gap: 10px;
}
.offer-cat-count { font-size: 13px; color: #667085; }
.offer-cat-sort { width: auto; }

/* ── Responsive ── */
@media (max-width: 991px) {
  .offer-layout { flex-direction: column; }
  .offer-sidebar {
    width: 100%;
    position: static;
    overflow-x: auto;
  }
  .offer-sidebar__inner { padding: 10px; }
  /* Pill nav on mobile */
  .offer-sidebar__cats {
    flex-direction: row;
    flex-wrap: nowrap;
    overflow-x: auto;
    gap: 6px;
    padding-bottom: 4px;
  }
  .offer-sidebar__cat-item { white-space: nowrap; flex-shrink: 0; }
  /* Filter sidebar stays vertical on mobile */
  .offer-filter-sidebar .offer-sidebar__cats { flex-direction: column; }
}
@media (max-width: 575px) {
  .offer-products-grid { grid-template-columns: repeat(2, minmax(0,1fr)) !important; }
}
```

- [ ] **Step 2: Apply same CSS to default/offers.css**

- [ ] **Step 3: Commit**

```bash
git add catalog/view/theme/dreamer/stylesheet/offers.css catalog/view/theme/default/stylesheet/offers.css
git commit -m "feat(css): add sidebar, filter, and category banner styles to offers"
```

---

## Task 7: Smoke test + cache clear

- [ ] **Step 1: Clear OpenCart cache**

Run: `rm -f /var/www/skyland/src/system/storage/cache/cache.* 2>/dev/null; echo done`

- [ ] **Step 2: Test offer detail page**

- Navigate to any active offer page `/offers/{slug}`
- Verify sidebar appears on left with category list
- Verify sticky behavior on scroll (≥992px)
- Verify mobile: sidebar becomes horizontal pill list
- Verify coupon + dates in sidebar
- Verify gallery and description hidden for upcoming offers

- [ ] **Step 3: Test category page**

- Click a category in sidebar → navigates to `/index.php?route=extension/module/offers/category&offer_slug=...&category_id=...`
- Verify banner shows with countdown + coupon
- Verify filter sidebar shows price range, brands, attributes
- Click "Apply Filters" → AJAX replaces product grid
- Verify sort dropdown re-sorts client-side
- Verify clear filters resets all

- [ ] **Step 4: Test expired offer category (404 guard)**

- Access category URL of expired offer → should return 404

- [ ] **Step 5: Final commit**

```bash
git add -A
git commit -m "chore: smoke test verified — offer frontend redesign complete"
```
