# Offer Manager Bug Fixes + Search Fixes — 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:** Fix 6 confirmed bugs in the offer module and fix the broken autocomplete search in the admin form.

**Architecture:** Targeted edits across 5 files. No new files. Each task is independently releasable and does not depend on Plan B or Plan C.

**Tech Stack:** PHP (OpenCart MVC), Twig templates, JavaScript (XHR), MySQL LIKE

---

## File Map

| File | Change |
|------|--------|
| `admin/model/catalog/product.php` | Lines 389, 668 — prefix LIKE → substring LIKE |
| `admin/model/catalog/manufacturer.php` | Line 82 — prefix LIKE → substring LIKE |
| `admin/view/template/extension/module/offer_manager_form.twig` | 4× `encodeURIComponent(request)` → `encodeURIComponent(request.term)` |
| `catalog/language/en-gb/extension/module/offers.php` | Add `text_load_more`, `text_products_found` |
| `catalog/view/theme/dreamer/template/extension/module/offers_info.twig` | Remove initial_limit; fix XHR else; upcoming guard on gallery/description; add global_badges |
| `catalog/view/theme/default/template/extension/module/offers_info.twig` | Same as dreamer |
| `catalog/view/theme/dreamer/template/extension/module/offers_category.twig` | Fix XHR else branch |
| `catalog/view/theme/default/template/extension/module/offers_category.twig` | Same as dreamer |
| `catalog/controller/extension/module/offers.php` | Remove initial_limit; add active state guard to category(); add global_badges to buildOfferProducts() |
| `catalog/model/extension/module/offers.php` | No change in this plan |

---

## Task 1: Fix admin product model — substring LIKE

**Files:**
- Modify: `admin/model/catalog/product.php:389`
- Modify: `admin/model/catalog/product.php:668`

- [ ] **Step 1: Open file and verify current content**

Run: `grep -n "filter_name.*LIKE" /var/www/skyland/src/admin/model/catalog/product.php`
Expected output includes:
```
389:			$sql .= " AND pd.name LIKE '" . $this->db->escape($data['filter_name']) . "%'";
668:			$sql .= " AND pd.name LIKE '" . $this->db->escape($data['filter_name']) . "%'";
```

- [ ] **Step 2: Fix line 389 in getProducts()**

In `admin/model/catalog/product.php` at line 389, change:
```php
			$sql .= " AND pd.name LIKE '" . $this->db->escape($data['filter_name']) . "%'";
```
to:
```php
			$sql .= " AND pd.name LIKE '%" . $this->db->escape($data['filter_name']) . "%'";
```

- [ ] **Step 3: Fix line 668 in getTotalProducts()**

In `admin/model/catalog/product.php` at line 668, change:
```php
			$sql .= " AND pd.name LIKE '" . $this->db->escape($data['filter_name']) . "%'";
```
to:
```php
			$sql .= " AND pd.name LIKE '%" . $this->db->escape($data['filter_name']) . "%'";
```

- [ ] **Step 4: Verify both edits**

Run: `grep -n "filter_name.*LIKE" /var/www/skyland/src/admin/model/catalog/product.php`
Expected: both lines now show `LIKE '%"` prefix.

- [ ] **Step 5: Commit**

```bash
git add admin/model/catalog/product.php
git commit -m "fix(admin): substring LIKE for product name autocomplete search"
```

---

## Task 2: Fix admin manufacturer model — substring LIKE

**Files:**
- Modify: `admin/model/catalog/manufacturer.php:82`

- [ ] **Step 1: Verify current content**

Run: `grep -n "filter_name.*LIKE" /var/www/skyland/src/admin/model/catalog/manufacturer.php`
Expected:
```
82:			$sql .= " WHERE name LIKE '" . $this->db->escape($data['filter_name']) . "%'";
```

- [ ] **Step 2: Apply fix**

In `admin/model/catalog/manufacturer.php` at line 82, change:
```php
			$sql .= " WHERE name LIKE '" . $this->db->escape($data['filter_name']) . "%'";
```
to:
```php
			$sql .= " WHERE name LIKE '%" . $this->db->escape($data['filter_name']) . "%'";
```

- [ ] **Step 3: Commit**

```bash
git add admin/model/catalog/manufacturer.php
git commit -m "fix(admin): substring LIKE for manufacturer name autocomplete search"
```

---

## Task 3: Fix autocomplete request.term in admin form

**Context:** jQuery UI's `source` callback receives `{term: "..."}` not a bare string. All 4 autocomplete inputs pass `request` directly to `encodeURIComponent()`, which serializes it as `%5Bobject%20Object%5D` → server returns zero results regardless of any LIKE fix.

**Files:**
- Modify: `admin/view/template/extension/module/offer_manager_form.twig` lines 322, 364, 390, 412

- [ ] **Step 1: Verify current broken code**

Run: `grep -n "encodeURIComponent(request)" /var/www/skyland/src/admin/view/template/extension/module/offer_manager_form.twig`
Expected: 4 matches at lines 322, 364, 390, 412.

- [ ] **Step 2: Fix all 4 occurrences — replace globally**

In `admin/view/template/extension/module/offer_manager_form.twig`, replace every instance of:
```javascript
encodeURIComponent(request)
```
with:
```javascript
encodeURIComponent(request.term)
```
There are exactly 4 occurrences — use replace_all.

- [ ] **Step 3: Verify**

Run: `grep -n "encodeURIComponent" /var/www/skyland/src/admin/view/template/extension/module/offer_manager_form.twig`
Expected: all 4 lines show `encodeURIComponent(request.term)`.

- [ ] **Step 4: Commit**

```bash
git add admin/view/template/extension/module/offer_manager_form.twig
git commit -m "fix(admin): pass request.term not request object to autocomplete encodeURIComponent"
```

---

## Task 4: Add missing language keys

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

- [ ] **Step 1: Verify keys don't already exist**

Run: `grep -n "text_load_more\|text_products_found" /var/www/skyland/src/catalog/language/en-gb/extension/module/offers.php`
Expected: no output.

- [ ] **Step 2: Add keys**

In `catalog/language/en-gb/extension/module/offers.php`, after the last `$_[` line, add:
```php
$_['text_load_more']        = 'Load More';
$_['text_products_found']   = '{{count}} product(s) found';
```

- [ ] **Step 3: Verify**

Run: `grep -n "text_load_more\|text_products_found" /var/www/skyland/src/catalog/language/en-gb/extension/module/offers.php`
Expected: 2 lines found.

- [ ] **Step 4: Commit**

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

---

## Task 5: Remove initial_limit from controller

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

- [ ] **Step 1: Verify current line**

Run: `grep -n "initial_limit" /var/www/skyland/src/catalog/controller/extension/module/offers.php`
Expected: line 131 `$data['initial_limit'] = 8;`

- [ ] **Step 2: Remove the line**

In `catalog/controller/extension/module/offers.php`, delete line 131:
```php
		$data['initial_limit'] = 8;
```

- [ ] **Step 3: Commit**

```bash
git add catalog/controller/extension/module/offers.php
git commit -m "fix(catalog): remove initial_limit — all products shown on offer detail page"
```

---

## Task 6: Add active state guard to category() controller

**Context:** `category()` currently has no state check. Expired/upcoming offers return a valid page. Spec: return 404 if state is not 'active'.

**Files:**
- Modify: `catalog/controller/extension/module/offers.php` — `category()` method, after line 191

- [ ] **Step 1: Read the current category() method to confirm guard location**

Read lines 177–224 of `catalog/controller/extension/module/offers.php`. The block to insert after is:
```php
		if (!$offer || !$category_id) {
			$this->response->addHeader($this->request->server['SERVER_PROTOCOL'] . ' 404 Not Found');
			$this->response->setOutput($this->load->controller('error/not_found'));
			return;
		}
```

- [ ] **Step 2: Insert state guard**

After the `!$offer || !$category_id` block (after line 191), insert:
```php

		if ($this->getOfferState($offer['date_start'], $offer['date_end']) !== 'active') {
			$this->response->addHeader($this->request->server['SERVER_PROTOCOL'] . ' 404 Not Found');
			$this->response->setOutput($this->load->controller('error/not_found'));
			return;
		}
```

- [ ] **Step 3: Verify guard is present**

Run: `grep -n "getOfferState" /var/www/skyland/src/catalog/controller/extension/module/offers.php`
Expected: 3 matches — one in detail(), one in search(), one new one in category().

- [ ] **Step 4: Commit**

```bash
git add catalog/controller/extension/module/offers.php
git commit -m "fix(catalog): return 404 for non-active offer category pages"
```

---

## Task 7: Add global_badges to buildOfferProducts()

**Context:** Product cards in the offer module don't show global badges from the badge manager. `getProductBadges()` is available on the catalog product model.

**Files:**
- Modify: `catalog/controller/extension/module/offers.php` — `buildOfferProducts()` method

- [ ] **Step 1: Check if getProductBadges exists on catalog product model**

Run: `grep -n "getProductBadges\|fetchProductBadges" /var/www/skyland/src/catalog/model/catalog/product.php | head -5`
Note the exact method name — use whatever is found.

- [ ] **Step 2: Add global_badges to the $products[] array in buildOfferProducts()**

In `catalog/controller/extension/module/offers.php`, inside `buildOfferProducts()`, find the existing `$products[] = array(` block ending at line ~336. After `'stock_status_name' => ...`, add:
```php
			'global_badges'     => method_exists($this->model_catalog_product, 'getProductBadges') ? $this->model_catalog_product->getProductBadges((int)$product['product_id']) : array(),
```

- [ ] **Step 3: Commit**

```bash
git add catalog/controller/extension/module/offers.php
git commit -m "feat(catalog): attach global_badges to offer product card data"
```

---

## Task 8: Fix offers_info.twig — remove initial_limit, fix XHR, fix upcoming guard, add global_badges

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

Do dreamer first, then copy equivalent changes to default.

### Bug 1.1 — Remove initial_limit from product loop

In `offers_info.twig` (dreamer), find:
```twig
      <div class="offer-product-layout{% if loop.index > initial_limit %} offer-product-layout--hidden{% endif %}">
```
Change to:
```twig
      <div class="offer-product-layout">
```

### Bug 1.1 — Remove Load More button

Find and delete the entire block:
```twig
    <div class="offer-products-more" id="offer-products-more" hidden>
      <a href="" class="offer-products-more__button" id="offer-products-load-more">Load More</a>
    </div>
```

### Bug 1.1 — Remove initial_limit and moreWrap vars from JS

In the JS block (around line 348), find:
```javascript
  var visibleLimit = {{ initial_limit|default(8) }};
```
Delete that line.

Find:
```javascript
  var moreWrap = document.getElementById('offer-products-more');
  var loadMore = document.getElementById('offer-products-load-more');
```
Delete both lines.

Find the `updateMore` function:
```javascript
  function updateMore(total) {
    if (!moreWrap || !loadMore) { return; }

    if (activeCategoryHref && total > visibleLimit) {
      loadMore.href = activeCategoryHref;
      moreWrap.hidden = false;
    } else {
      moreWrap.hidden = true;
    }
  }
```
Delete the entire `updateMore` function.

Remove all calls to `updateMore(...)` (they appear in `renderProducts()`).

In `renderProduct()` function, find:
```javascript
    return '' +
      '<div class="offer-product-layout' + (index >= visibleLimit ? ' offer-product-layout--hidden' : '') + '">' +
```
Change to:
```javascript
    return '' +
      '<div class="offer-product-layout">' +
```
Also remove the `index` parameter from `renderProduct(product, index)` → `renderProduct(product)`.
Update the `.map(renderProduct)` call — no change needed since `Array.map` passes index but it's now ignored.

### Bug 1.2 — Fix XHR else branch

In the `onreadystatechange` handler:
```javascript
    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([]);
        }
      }
    };
```
Change to:
```javascript
    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 (status) { status.textContent = ''; }
        renderProducts([]);
      }
    };
```

### Bug 1.3 — text_products_found language var

In the JS block, find where `textSearching` is set (around line 345):
```javascript
  var textSearching = {{ text_searching|json_encode|raw }};
```
Add after it:
```javascript
  var textProductsFound = {{ text_products_found|json_encode|raw }};
```

In `renderProducts()`, find:
```javascript
    if (status) {
      status.textContent = input.value.trim() ? products.length + ' product(s) found' : '';
    }
```
Change to:
```javascript
    if (status) {
      status.textContent = input.value.trim() ? textProductsFound.replace('{{count}}', products.length) : '';
    }
```

### Bug 1.5 — upcoming guard on gallery and description

Find the gallery block:
```twig
  {# ── Gallery ── #}
  {% if offer_images %}
```
Change to:
```twig
  {# ── Gallery ── #}
  {% if offer_images and offer.state != 'upcoming' %}
```

Find the description block:
```twig
  {# ── Description ── #}
  {% if offer.description %}
```
Change to:
```twig
  {# ── Description ── #}
  {% if offer.description and offer.state != 'upcoming' %}
```

### Bug 1.6 — global_badges in product card

In the Twig product loop, find the product card image section:
```twig
          <div class="image">
            {% if product.save %}<span class="badge-save">{{ text_save }} {{ product.save }}</span>{% endif %}
            <a href="{{ product.href }}">
```
Add badge stack before the save badge:
```twig
          <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 }}">
```

In the JS `renderProduct()` function, add badge rendering. Find the image section in `renderProduct`:
```javascript
      '    <div class="image">' +
      (product.save ? '      <span class="badge-save">' + escapeHtml(textSave + ' ' + product.save) + '</span>' : '') +
```
Change to:
```javascript
      '    <div class="image">' +
      (product.global_badges && product.global_badges.length ? 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('') : '') +
      (product.save ? '      <span class="badge-save">' + escapeHtml(textSave + ' ' + product.save) + '</span>' : '') +
```

- [ ] **Step 1: Apply all 8 sub-changes to dreamer/offers_info.twig**

Apply every change listed above in order.

- [ ] **Step 2: Apply equivalent changes to default/offers_info.twig**

`catalog/view/theme/default/template/extension/module/offers_info.twig` has the same structure. Apply the same set of changes.

- [ ] **Step 3: Verify — no initial_limit references remain**

Run: `grep -rn "initial_limit\|offer-product-layout--hidden\|offer-products-more\|offer-products-load-more" /var/www/skyland/src/catalog/view/theme/`
Expected: zero results.

- [ ] **Step 4: 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 "fix(frontend): remove initial_limit, fix XHR error handler, upcoming guard on gallery/description, add global_badges"
```

---

## Task 9: Fix offers_category.twig — XHR else branch + text_products_found

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

- [ ] **Step 1: Fix XHR else branch in dreamer/offers_category.twig**

Current `onreadystatechange` (line 158):
```javascript
  activeRequest.onreadystatechange = function() { if (activeRequest.readyState !== 4) { return; } if (activeRequest.status >= 200 && activeRequest.status < 300) { try { renderProducts((JSON.parse(activeRequest.responseText).products || [])); } catch (e) { renderProducts([]); } } };
```
Change to:
```javascript
  activeRequest.onreadystatechange = function() { if (activeRequest.readyState !== 4) { return; } if (activeRequest.status >= 200 && activeRequest.status < 300) { try { renderProducts((JSON.parse(activeRequest.responseText).products || [])); } catch (e) { renderProducts([]); } } else { if (status) { status.textContent = ''; } renderProducts([]); } };
```

- [ ] **Step 2: Add text_products_found var and use it in renderProducts**

In the JS block, after `var textNoProducts = ...;` add:
```javascript
  var textProductsFound = {{ text_products_found|json_encode|raw }};
```

In `renderProducts()`, find:
```javascript
  function renderProducts(products) {
    if (status) { status.textContent = input.value.trim() ? products.length + ' product(s) found' : ''; }
```
Change to:
```javascript
  function renderProducts(products) {
    if (status) { status.textContent = input.value.trim() ? textProductsFound.replace('{{count}}', products.length) : ''; }
```

- [ ] **Step 3: Apply same changes to default/offers_category.twig**

- [ ] **Step 4: 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 "fix(frontend): fix XHR error handler and use language key in category search"
```

---

## Task 10: Remove dead CSS classes from both theme stylesheets

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

- [ ] **Step 1: Check what dead classes exist**

Run: `grep -n "offer-product-layout--hidden\|offer-products-more" /var/www/skyland/src/catalog/view/theme/dreamer/stylesheet/offers.css`
Run: `grep -n "offer-product-layout--hidden\|offer-products-more" /var/www/skyland/src/catalog/view/theme/default/stylesheet/offers.css`

- [ ] **Step 2: Delete matching CSS blocks**

Delete any `.offer-product-layout--hidden { ... }` and `.offer-products-more { ... }` rules found in both files.

- [ ] **Step 3: Commit**

```bash
git add catalog/view/theme/dreamer/stylesheet/offers.css catalog/view/theme/default/stylesheet/offers.css
git commit -m "chore: remove dead offer CSS classes (initial_limit era)"
```
