⚡ 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
| Error | Causa | Solucion |
|---|---|---|
| 404 en la URL AJAX | Controller no encontrado por el Dispatcher | Verifica naming: {ModuleName}{Controller}ModuleFrontController y archivo en controllers/front/{controller}.php |
| 403 Forbidden | Token CSRF invalido o expirado | Regenera token con Tools::getToken(false) y pasalo con Media::addJsDef |
| 500 Internal Error | Error PHP en el controller | Revisa logs en var/logs/ o activa display_errors temporalmente |
| CORS blocked | Peticion desde dominio diferente | Anade header Access-Control-Allow-Origin en tu controller si es necesario |
| JSON parse error | El controller devuelve HTML (error PS) | Asegura $this->ajax = true y que no haces parent::initContent() |
| Empty response | exit/die sin output | Usa 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.