⚡ AJAX con ModuleFrontController — patron completo

Actualizado: 2025-01-15
💡
Patron reutilizable

Este patron AJAX es el estandar en modulos PS 1.7+. Funciona con cualquier tipo de dato: productos, carrito, wishlists, formularios custom, etc.

#Arquitectura del patron AJAX

El flujo AJAX en PrestaShop sigue este camino: JS del modulo hace fetch a una URL generada por getModuleLink() → Apache/Nginx enruta a index.php → Dispatcher detecta module-{module_name}-{controller} → Ejecuta tu ModuleFrontController → Tu controller devuelve JSON.

Flujo de una peticion AJAX
text
Browser JS
  │
  ▼ fetch('/module/mimodulo/ajax?action=getProducts&token=abc123')
  │
  ▼ .htaccess → index.php → Dispatcher
  │
  ▼ modules/mimodulo/controllers/front/ajax.php
  │
  ▼ MimoduloAjaxModuleFrontController::initContent()
  │
  ▼ JSON Response → Browser

#ModuleFrontController — el endpoint

controllers/front/ajax.php
php
<?php
/**
 * modules/mimodulo/controllers/front/ajax.php
 * Naming: {ModuleName}{ControllerName}ModuleFrontController
 */
class MimoduloAjaxModuleFrontController extends ModuleFrontController
{
    /** @var bool Desactivar renderizado de pagina completa */
    public $ajax = true;

    /** @var bool Permitir acceso sin login (true) o requerir login (false) */
    public $auth = false;

    public function initContent()
    {
        // Validar token CSRF (SIEMPRE en produccion)
        if (!$this->isTokenValid()) {
            $this->jsonResponse(['error' => 'Token invalido'], 403);
            return;
        }

        $action = Tools::getValue('action');

        switch ($action) {
            case 'getProducts':
                $this->ajaxGetProducts();
                break;
            case 'addToWishlist':
                $this->ajaxAddToWishlist();
                break;
            case 'removeFromWishlist':
                $this->ajaxRemoveFromWishlist();
                break;
            default:
                $this->jsonResponse(['error' => 'Accion no valida'], 400);
        }
    }

    /**
     * Validar token CSRF de PrestaShop
     */
    protected function isTokenValid(): bool
    {
        $token = Tools::getValue('token');
        return $token === Tools::getToken(false);
    }

    /**
     * Buscar productos por termino
     */
    protected function ajaxGetProducts(): void
    {
        $query = Tools::getValue('q', '');
        $limit = (int) Tools::getValue('limit', 10);

        if (empty($query) || strlen($query) < 2) {
            $this->jsonResponse(['error' => 'Busqueda muy corta'], 400);
            return;
        }

        $products = Db::getInstance()->executeS(
            (new DbQuery())
                ->select('p.id_product, pl.name, p.price, p.reference')
                ->from('product', 'p')
                ->innerJoin('product_lang', 'pl',
                    'p.id_product = pl.id_product AND pl.id_lang = ' . (int) $this->context->language->id
                )
                ->innerJoin('product_shop', 'ps',
                    'p.id_product = ps.id_product AND ps.id_shop = ' . (int) $this->context->shop->id
                )
                ->where('ps.active = 1')
                ->where('pl.name LIKE \'%' . pSQL($query) . '%\'')
                ->orderBy('pl.name ASC')
                ->limit($limit)
                ->build()
        );

        $this->jsonResponse([
            'success'  => true,
            'products' => $products ?? [],
            'count'    => count($products ?? []),
        ]);
    }

    /**
     * Anadir producto a wishlist
     */
    protected function ajaxAddToWishlist(): void
    {
        // Requiere login
        if (!$this->context->customer->isLogged()) {
            $this->jsonResponse(['error' => 'Debes iniciar sesion'], 401);
            return;
        }

        $idProduct = (int) Tools::getValue('id_product');
        $idProductAttribute = (int) Tools::getValue('id_product_attribute', 0);

        if ($idProduct <= 0) {
            $this->jsonResponse(['error' => 'Producto no valido'], 400);
            return;
        }

        // Verificar que el producto existe y esta activo
        $product = new Product($idProduct, false, $this->context->language->id);
        if (!Validate::isLoadedObject($product) || !$product->active) {
            $this->jsonResponse(['error' => 'Producto no encontrado'], 404);
            return;
        }

        $result = Db::getInstance()->insert('mimodulo_wishlist', [
            'id_customer'           => (int) $this->context->customer->id,
            'id_product'            => $idProduct,
            'id_product_attribute'  => $idProductAttribute,
            'id_shop'               => (int) $this->context->shop->id,
            'date_add'              => date('Y-m-d H:i:s'),
        ]);

        $this->jsonResponse([
            'success' => (bool) $result,
            'message' => $result ? 'Producto anadido a tu lista' : 'Error al guardar',
        ]);
    }

    /**
     * Helper: enviar respuesta JSON y terminar
     */
    protected function jsonResponse(array $data, int $httpCode = 200): void
    {
        http_response_code($httpCode);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($data, JSON_UNESCAPED_UNICODE);
        exit;
    }
}

#Registro del JS y variables

Metodo principal del modulo — hookActionFrontControllerSetMedia
php
<?php
/**
 * Hook para registrar JS y pasar variables al front
 */
public function hookActionFrontControllerSetMedia($params)
{
    // Solo en paginas relevantes (producto, categoria, home...)
    $controller = Tools::getValue('controller');
    $allowedPages = ['product', 'category', 'index', 'search'];

    if (!in_array($controller, $allowedPages)) {
        return;
    }

    // Registrar JS del modulo
    $this->context->controller->registerJavascript(
        'module-mimodulo-front',
        'modules/' . $this->name . '/views/js/front.js',
        ['position' => 'bottom', 'priority' => 150]
    );

    // Pasar variables PHP al JS (disponibles como window.mimodulo_ajax_url etc.)
    Media::addJsDef([
        'mimodulo_ajax_url' => $this->context->link->getModuleLink(
            $this->name, 'ajax', [], true
        ),
        'mimodulo_token' => Tools::getToken(false),
        'mimodulo_is_logged' => (bool) $this->context->customer->isLogged(),
    ]);

    // CSS opcional
    $this->context->controller->registerStylesheet(
        'module-mimodulo-front',
        'modules/' . $this->name . '/views/css/front.css',
        ['media' => 'all', 'priority' => 150]
    );
}

#JavaScript — fetch con manejo de errores

views/js/front.js — Cliente AJAX con fetch
javascript
/**
 * Modulo AJAX client — PrestaShop
 * Variables inyectadas por Media::addJsDef:
 *   - mimodulo_ajax_url  (URL base del controller)
 *   - mimodulo_token     (token CSRF)
 *   - mimodulo_is_logged (bool)
 */
document.addEventListener('DOMContentLoaded', function () {

    // === Buscar productos ===
    const searchInput = document.getElementById('mimodulo-search');
    const resultsContainer = document.getElementById('mimodulo-results');

    if (searchInput) {
        let debounceTimer;
        searchInput.addEventListener('input', function () {
            clearTimeout(debounceTimer);
            debounceTimer = setTimeout(() => {
                const query = this.value.trim();
                if (query.length >= 2) {
                    searchProducts(query);
                } else {
                    resultsContainer.innerHTML = '';
                }
            }, 300); // Debounce 300ms
        });
    }

    async function searchProducts(query) {
        try {
            const url = new URL(mimodulo_ajax_url);
            url.searchParams.set('action', 'getProducts');
            url.searchParams.set('token', mimodulo_token);
            url.searchParams.set('q', query);
            url.searchParams.set('limit', '10');

            const response = await fetch(url.toString(), {
                method: 'GET',
                headers: { 'X-Requested-With': 'XMLHttpRequest' },
            });

            if (!response.ok) {
                throw new Error(`HTTP ${response.status}`);
            }

            const data = await response.json();

            if (data.success && data.products.length > 0) {
                resultsContainer.innerHTML = data.products
                    .map(p => `<div class="mimodulo-result">
                        <strong>${p.name}</strong>
                        <span>${p.reference}</span>
                    </div>`)
                    .join('');
            } else {
                resultsContainer.innerHTML = '<p>No se encontraron productos</p>';
            }
        } catch (error) {
            console.error('MiModulo AJAX error:', error);
            resultsContainer.innerHTML = '<p class="text-danger">Error de conexion</p>';
        }
    }

    // === Wishlist (POST) ===
    document.querySelectorAll('.mimodulo-wishlist-btn').forEach(btn => {
        btn.addEventListener('click', async function (e) {
            e.preventDefault();

            if (!mimodulo_is_logged) {
                // Redirigir a login
                window.location.href = prestashop.urls.pages.authentication
                    + '?back=' + encodeURIComponent(window.location.href);
                return;
            }

            const idProduct = parseInt(this.dataset.idProduct, 10);
            const idPa = parseInt(this.dataset.idProductAttribute || '0', 10);

            try {
                const response = await fetch(mimodulo_ajax_url, {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded',
                        'X-Requested-With': 'XMLHttpRequest',
                    },
                    body: new URLSearchParams({
                        action: 'addToWishlist',
                        token: mimodulo_token,
                        id_product: idProduct,
                        id_product_attribute: idPa,
                    }),
                });

                const data = await response.json();

                if (data.success) {
                    this.classList.add('active');
                    this.title = data.message;
                    // Feedback visual
                    showToast(data.message, 'success');
                } else {
                    showToast(data.error || 'Error', 'error');
                }
            } catch (error) {
                console.error('Wishlist error:', error);
                showToast('Error de conexion', 'error');
            }
        });
    });

    function showToast(message, type) {
        const toast = document.createElement('div');
        toast.className = `mimodulo-toast mimodulo-toast--${type}`;
        toast.textContent = message;
        document.body.appendChild(toast);
        setTimeout(() => toast.remove(), 3000);
    }
});

#Alternativa: AJAX desde admin (BO)

En el Back Office, el patron es diferente. No usas ModuleFrontController sino el metodo ajaxProcess{Action} dentro de tu AdminController o getContent() del modulo.

AJAX en AdminController del modulo
php
<?php
// En tu AdminController (admin/mimodulo_ajax.php)
class AdminMimoduloAjaxController extends ModuleAdminController
{
    public function ajaxProcessSearchProducts()
    {
        $query = Tools::getValue('q', '');

        $products = Product::searchByName(
            $this->context->language->id,
            $query
        );

        // ajaxProcess* metodos usan die() con JSON
        die(json_encode([
            'success'  => true,
            'products' => array_slice($products, 0, 20),
        ]));
    }
}

// En el JS del admin:
// $.ajax({
//     url: currentIndex + '&ajax=1&action=searchProducts&token=' + token,
//     data: { q: 'camiseta' },
//     success: function(data) { ... }
// });

#Errores comunes y soluciones

ErrorCausaSolucion
404 en la URL AJAXController no encontrado por el DispatcherVerifica naming: {ModuleName}{Controller}ModuleFrontController y archivo en controllers/front/{controller}.php
403 ForbiddenToken CSRF invalido o expiradoRegenera token con Tools::getToken(false) y pasalo con Media::addJsDef
500 Internal ErrorError PHP en el controllerRevisa logs en var/logs/ o activa display_errors temporalmente
CORS blockedPeticion desde dominio diferenteAnade header Access-Control-Allow-Origin en tu controller si es necesario
JSON parse errorEl controller devuelve HTML (error PS)Asegura $this->ajax = true y que no haces parent::initContent()
Empty responseexit/die sin outputUsa el helper jsonResponse() que incluye headers correctos
Descargar en Markdown Pensado para pegar en ChatGPT, Claude u otra IA. Incluye solo el contenido de esta pagina.