MenuManager en Laravel
Aprende a construir un sistema de menús dinámicos en Laravel 12 desde cero. Guía paso a paso con código y ejemplos prácticos para aplicaciones web escalables
INDICE
- 1. Configuración Inicial: Menumanager
- 2. Migración y Modelo: Menús
- 3. Migración y Modelo: Pages
- 4. Seeders : Menus, Pages
- 5. Rutas
- 6. El Controlador: MenuController
- 7. Vistas blade para gestionar el menu
- 8. ViewComposer: MenuComposerServiceProvider
- 9. El Menú de navegación dinámico
- 10. Archivos que incluyen en la descarga
- Descargar fuente código 41.85 KB
Introducción: ¿Por qué un Menu Builder en Laravel?
Si has desarrollado aplicaciones con Laravel, sabes que gestionar menús estáticos en Blade se convierte en una pesadilla cuando tu app crece.
Un gestor de menús dinámicos centraliza toda la lógica de navegación, la hace reutilizable sin ensuciar tus vistas.
En este tutorial, crearás un sistema listo para producción con Laravel 12, usando:
- Gates para control de acceso
- Clases Builder para modularidad
- Caching para performance
- HTML semántico para accesibilidad
¿Quieres el código completo? Descarga el código al final del artículo.
Configuración Inicial: Menumanager
Configuración Inicial: Menumanager
Requisitos Previos {#requisitos}
- Laravel 12+ instalado
- PHP 8.3 o superior
- Laravel/ui
- Conocimientos básicos de Service Providers
Migración y Modelo: Menús
Migración y Modelo: Menús
Paso 1: Crear la Migración
En la terminal (raíz del proyecto), ejecuta:
php artisan make:migration create_menus_table
Esto genera un archivo en database/migrations/ (ej. 2023_xx_xx_create_menus_table.php). Ábrelo y reemplaza el contenido con esto:
PHP
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('menus', function (Blueprint $table) {
$table->id();
$table->string('label'); // Texto visible
$table->string('url')->nullable(); // Para enlaces manuales
$table->string('target')->default('_self'); // _self o _blank
$table->unsignedBigInteger('parent_id')->nullable(); // Para submenús
$table->integer('order')->default(0); // Orden visual
// Polimorfismo (menuable_id, menuable_type)
// Permite vincular: Pages, Posts, Services
$table->nullableMorphs('menuable');
$table->timestamps();
// FK recursiva
$table->foreign('parent_id')->references('id')->on('menus')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('menus');
}
};
Paso 2: Ejecutar la Migración
php artisan migrate
¡Listo! La tabla menus está creada. Si quieres rollback: php artisan migrate:rollback.
Paso 3: Crear el Modelo
Ejecuta:
php artisan make:model Menu
Esto genera app/Models/Menu.php. Ábrelo y reemplaza con esto:
PHP
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Menu extends Model
{
protected $fillable = ['label', 'url', 'target', 'parent_id', 'order', 'menuable_id', 'menuable_type'];
// Relación con el objeto real (Page)
public function menuable(): MorphTo
{
return $this->morphTo();
}
// Relación recursiva: Hijos
public function children()
{
return $this->hasMany(Menu::class, 'parent_id')->orderBy('order');
}
// Accesor: Obtener la URL final automáticamente
public function getHrefAttribute()
{
// 1. Si es un enlace personalizado manual, retornar eso.
if ($this->url) {
return $this->url;
}
// 2. Si está vinculado a un modelo (DB)
if ($this->menuable) {
// Obtenemos el slug del registro vinculado
$slug = $this->menuable->slug;
// Usamos match (PHP 8+) para decidir el prefijo de la URL
return match ($this->menuable_type) {
'App\Models\Page' => url("/page/{$slug}"),
default => '#'
};
}
return '#';
}
}
¡Ya está! El modelo soporta submenús recursivos, enlaces manuales y URLs dinámicas vía polimorfismo. Prueba creando un menú: php artisan tinker y Menu::create(['label' => 'Prueba', 'url' => '/']);.
Migración y Modelo: Pages
Migración y Modelo: Pages
Migración
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('pages', function (Blueprint $table) {
$table->id();
$table->string('slug',100);
$table->string('name',100)->unique();
$table->text('content')->nullable();
$table->string('image_url',100)->nullable();
$table->string('meta_title',67);
$table->string('meta_description',170);
$table->integer('pos')->default(0);
$table->boolean('active')->default(0);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('pages');
}
};
Modelo:
namespace App\Models;
use Illuminate\Support\Str;
use Illuminate\Database\Eloquent\Model;
class Page extends Model
{
public $timestamps = false;
protected $fillable = [
'slug',
'name',
'content',
'image_url',
'meta_title',
'meta_description',
'pos',
'active'
];
public static function boot()
{
parent::boot();
static::saving(function ($page) {
$page->slug = Str::slug($page->name);
});
}
}
Seeders : Menus, Pages
Seeders : Menus, Pages
Aquí tienes el flujo completo, desde la creación hasta la ejecución, con buenas prácticas integradas:
Comando de Creación
Primero genera el archivo (si aún no lo tienes):
php artisan make:seeder MenuSeeder
Eso creará database/seeders/MenuSeeder.php con la estructura básica.
Seeder Completo y Optimizado
Reemplaza el contenido con esta versión escalable:
namespace Database\Seeders;
use App\Models\Menu;
use Illuminate\Database\Seeder;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
class MenuSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// Inicio (Enlace Estático Personalizado)
Menu::create([
'label' => 'Inicio',
'url' => '/',
'order' => 0,
'target'=> '_self'
]);
// Blog (Enlace Estático Personalizado)
Menu::create([
'label' => 'Blog',
'url' => '/blog',
'order' => 1,
]);
Menu::create([
'label' => 'Link Externo',
'url' => 'https://whatsapp.com',
'target' => '_blank', // Abrir en nueva pestaña
'order' => 4,
]);
}
}
Instrucciones de Ejecución
Opción A - Ejecutar solo este seeder:
php artisan db:seed --class=MenuSeeder
Opción B - Incluirlo en el DatabaseSeeder principal (database/seeders/DatabaseSeeder.php):
public function run(): void {
$this->call([ MenuSeeder::class, // otros seeders... ]);
}
Luego ejecuta:
php artisan db:seed
Opción C - En desarrollo, migrar y sembrar todo de nuevo:
php artisan migrate:fresh --seed
Claves de Mejora
-
✅ Estructura array: Fácil de leer y expandir
-
✅
firstOrCreate(): Idempotente (ejecuta múltiples veces sin duplicar) -
✅
use WithoutModelEvents: Mejora el rendimiento -
✅ Verifica tu migración tenga los campos necesarios
Page:
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Page; // Importa el modelo Page
use Illuminate\Support\Str; // Importa la clase Str para generar el slug
class PageSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Page::create([
'name' => 'Acerca de Nosotros',
'slug'=>'nosotros',
'content' => 'Contenido detallado sobre la misión y visión de la empresa.',
'image_url' => 'images/about-us.jpg',
'meta_title' => 'Conoce Nuestra Empresa',
'meta_description' => 'Información clave sobre quiénes somos.',
'pos' => 1,
'active' => true,
]);
// Segundo Registro
Page::create([
'name' => 'Política de Privacidad',
'slug'=>'politicas',
'content' => 'Documento completo sobre nuestra política de manejo de datos.',
'image_url' => 'images/privacy.jpg',
'meta_title' => 'Política de Datos',
'meta_description' => 'Revisa cómo protegemos tu información.',
'pos' => 2,
'active' => true,
]);
// Tercer Registro
Page::create([
'name' => 'Términos y Condiciones',
'slug'=>'terminos',
'content' => 'Reglas y acuerdos para el uso de nuestro servicio.',
'image_url' => 'images/terms.jpg',
'meta_title' => 'Términos de Uso',
'meta_description' => 'Consulta nuestros términos legales.',
'pos' => 3,
'active' => true,
]);
}
}
🏃 Pasos para Ejecutar el Seeder
Para que estos datos se inserten en tu base de datos, debes seguir dos pasos:
Registrar el Seeder
Abre el archivo database/seeders/DatabaseSeeder.php y llama a tu nuevo seeder dentro del método run:
PHP
// database/seeders/DatabaseSeeder.php
use Database\Seeders\PageSeeder; // Importa tu seeder
public function run()
{
// Llama a tu seeder
$this->call(PageSeeder::class);
// ... otros seeders si los tienes
}
Ejecutar la Siembra
Finalmente, ejecuta el comando Artisan para poblar la base de datos:
php artisan db:seed
O, si quieres refrescar las migraciones y sembrar de nuevo (¡CUIDADO, esto borrará todos los datos existentes en tus tablas!):
php artisan migrate:fresh --seed
Esto insertará los tres registros con los datos que definiste en la tabla pages (asumiendo que tu tabla se llama así por convención de Laravel).
Rutas
Rutas
// Rutas del Gestor de Menú
Route::get('/menu', [MenuController::class, 'index'])->name('admin.menu.index');
Route::post('/menu/store', [MenuController::class, 'store'])->name('admin.menu.store');
Route::post('/menu/update-tree', [MenuController::class, 'updateTree'])->name('admin.menu.updateTree'); // AJAX Orden
Route::put('/menu/{menuItem}', [MenuController::class, 'update'])->name('admin.menu.update'); // AJAX Edición rápida
Route::delete('/menu/{menuItem}', [MenuController::class, 'destroy'])->name('admin.menu.destroy');
Estas rutas se definen en routes/web.php (o en un archivo de rutas admin con middleware de autenticación). Forman un CRUD personalizado para menús, con énfasis en operaciones AJAX para un gestor de árbol (orden y edición sin recarga). Cada ruta incluye método HTTP, endpoint, controlador/método, nombre y propósito.
- GET /menu
- Controlador: MenuController@index
- Nombre: menu.index
- Propósito: Muestra la vista principal del gestor, cargando todos los menús como árbol (raíces con hijos recursivos) para visualización y edición.
- POST /menu/store
- Controlador: MenuController@store
- Nombre: menu.store
- Propósito: Crea un nuevo ítem de menú. Recibe datos del formulario o AJAX (label, url, parent_id, order, etc.). Calcula el orden automáticamente si no se proporciona. Devuelve JSON con el nuevo menú.
- POST /menu/update-tree
- Controlador: MenuController@updateTree
- Nombre: menu.updateTree
- Propósito: Actualiza la estructura y orden del árbol completo vía AJAX. Espera un array JSON de objetos {id, parent_id, order} desde el frontend (ej. después de drag-and-drop). Realiza un bulk update eficiente en la base de datos.
- PUT /menu/{menuItem}
- Controlador: MenuController@update (inyección de modelo por ID en {menuItem})
- Nombre: menu.update
- Propósito: Actualiza un ítem específico vía AJAX (edición inline). Recibe campos como label, url, target o menuable. Devuelve JSON con el menú actualizado. No afecta el orden del árbol.
- DELETE /menu/{menuItem}
- Controlador: MenuController@destroy (inyección de modelo por ID en {menuItem})
- Nombre: menu.destroy
- Propósito: Elimina un ítem de menú (y sus hijos por cascade de la FK). Verifica si tiene submenús antes de borrar. Devuelve JSON con estado de éxito o error (ej. si no se puede eliminar por dependencias).
Notas:
- Las rutas 2-5 son AJAX-friendly y devuelven JSON para integración con frontend (JS/Vue).
- Usa route('menu.index') en vistas para generar URLs.
- Recomendación: Agrupa con Route::prefix('admin')->middleware(['auth', 'admin']) para seguridad.
- Para listar: php artisan route:list --name=menu.
El Controlador: MenuController
El Controlador: MenuController
Controlador MenuController
use App\Models\Menu;
use App\Models\Page;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class MenuController extends Controller
{
public function index()
{
// Cargar solo raíces, eager loading de hijos
$menuItems = Menu::whereNull('parent_id')->with('children')->orderBy('order')->get();
// Datos para llenar los selectores laterales
$pages = Page::select('id', 'name')->get();
return view('admin.menu.index', compact('menuItems', 'pages'));
}
public function store(Request $request)
{
// Agregar enlace personalizado
if ($request->type === 'custom') {
Menu::create([
'label' => $request->label,
'url' => $request->url,
'target' => '_self'
]);
return back()->with('success', 'Enlace agregado.');
}
// Agregar Items desde BD (Páginas/Servicios)
if ($request->has('items')) {
$class = "App\\Models\\" . $request->type;
foreach ($request->items as $id) {
$model = $class::find($id);
if ($model) {
Menu::create([
'label' => $model->title ?? $model->name, // Ajustar al campo de tu tabla
'menuable_type' => $class,
'menuable_id' => $model->id,
]);
}
}
return back()->with('success', 'Elementos agregados.');
}
return back();
}
// Guardar estructura (Drag & Drop)
public function updateTree(Request $request)
{
$structure = $request->input('structure');
$this->saveStructure($structure);
return response()->json(['status' => 'ok']);
}
// Función recursiva para guardar orden y padres
private function saveStructure($items, $parentId = null)
{
foreach ($items as $index => $item) {
$menuItem = Menu::find($item['id']);
if($menuItem) {
$menuItem->parent_id = $parentId;
$menuItem->order = $index;
$menuItem->save();
if (isset($item['children']) && !empty($item['children'])) {
$this->saveStructure($item['children'], $menuItem->id);
}
}
}
}
// Actualizar Label o Target (Edición Rápida)
public function update(Request $request, Menu $menuItem)
{
$menuItem->update($request->only(['label', 'target', 'url']));
return back()->with('success', 'Ítem actualizado.');
}
public function destroy(Menu $menuItem)
{
$menuItem->delete(); // Borra en cascada por la migración
return back()->with('success', 'Ítem eliminado.');
}
}
Vistas blade para gestionar el menu
Vistas blade para gestionar el menu
Ahora vamos por las vistas
admin/menu/index.blade.php
@extends('layouts.app') @section('content') <div class="container"> <div class="row justify-content-center"> <div class="col-md-10"> <div class="card bg-white"> <div class="card-header bg-dark text-white d-flex justify-content-between align-items-center"> <b>Menu</b> <div class="btn-group"> <button id="saveBtn" class="btn btn-light btn-sm"> Guardar Orden</button> </div> </div> <div class="card-body"> @include('admin.partials.message') <div class="row"> <div class="col-md-4"> <div class="accordion" id="accordionAdd"> <!-- PAGINAS--> <div class="accordion-item"> <h2 class="accordion-header"> <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#cPages">Páginas</button> </h2> <div id="cPages" class="accordion-collapse collapse " data-bs-parent="#accordionAdd"> <div class="accordion-body"> <form action="{{ route('admin.menu.store') }}" method="POST"> @csrf <input type="hidden" name="type" value="Page"> <div class="mb-3 overflow-auto" style="max-height:150px;"> @foreach ($pages as $p) <div class="form-check"> <input class="form-check-input" type="checkbox" name="items[]" value="{{ $p->id }}"> <label class="form-check-label">{{ $p->name }}</label> </div> @endforeach </div> <button class="btn btn-sm btn-outline-secondary w-100">Agregar</button> </form> </div> </div> </div> <!-- PERSONALIZADO--> <div class="accordion-item"> <h2 class="accordion-header"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#cCustom">Enlace Personalizado</button></h2> <div id="cCustom" class="accordion-collapse collapse" data-bs-parent="#accordionAdd"> <div class="accordion-body"> <form action="{{ route('admin.menu.store') }}" method="POST"> @csrf <input type="hidden" name="type" value="custom"> <input type="text" name="label" class="form-control form-control-sm mb-2" placeholder="Etiqueta" required> <input type="text" name="url" class="form-control form-control-sm mb-2" placeholder="http://" required> <button class="btn btn-sm btn-outline-secondary w-100">Agregar</button> </form> </div> </div> </div> </div> </div> <div class="col-md-8"> <ul id="menu-root" class="list-group sortable-list"> @foreach ($menuItems as $item) @include('admin.partials.item', ['item' => $item]) @endforeach </ul> </div> </div> <div class="modal fade" id="editModal" tabindex="-1"> <div class="modal-dialog"> <form id="editForm" method="POST" class="modal-content"> @csrf @method('PUT') <div class="modal-header"> <h5 class="modal-title">Editar Ítem</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div class="mb-3"> <label>Etiqueta</label> <input type="text" name="label" id="modalLabel" class="form-control"> </div> <div class="mb-3"> <label>URL (Solo si es personalizado)</label> <input type="text" name="url" id="modalUrl" class="form-control"> </div> <div class="mb-3"> <label>Abrir en</label> <select name="target" id="modalTarget" class="form-select"> <option value="_self">Misma pestaña</option> <option value="_blank">Nueva pestaña</option> </select> </div> </div> <div class="modal-footer"> <button type="submit" class="btn btn-primary">Guardar Cambios</button> </div> </form> </div> </div> </div> </div> </div> </div> </div> <script> // 1. Configurar Sortable (Drag & Drop) Recursivo function initSortable() { var nestedSortables = [].slice.call(document.querySelectorAll('.sortable-list')); nestedSortables.forEach(function(el) { new Sortable(el, { group: 'nested', animation: 150, fallbackOnBody: true, swapThreshold: 0.65 }); }); } initSortable(); // 2. Guardar Orden document.getElementById('saveBtn').addEventListener('click', function() { let structure = serializeMenu(document.getElementById('menu-root')); axios.post('{{ route('admin.menu.updateTree') }}', { structure: structure, _token: '{{ csrf_token() }}' }).then(res => alert('Orden guardado!')).catch(err => alert('Error')); }); function serializeMenu(list) { let items = []; [...list.children].forEach(li => { if (li.dataset.id) { let item = { id: li.dataset.id, children: [] }; let subList = li.querySelector('ul'); if (subList) item.children = serializeMenu(subList); items.push(item); } }); return items; } // 3. Lógica del Modal Editar var editModal = document.getElementById('editModal'); editModal.addEventListener('show.bs.modal', function(event) { var button = event.relatedTarget; var id = button.getAttribute('data-id'); var label = button.getAttribute('data-label'); var url = button.getAttribute('data-url'); var target = button.getAttribute('data-target'); var form = document.getElementById('editForm'); form.action = '/admin/menu/' + id; // URL update document.getElementById('modalLabel').value = label; document.getElementById('modalUrl').value = url; document.getElementById('modalTarget').value = target; }); </script> <style> .sortable-list { min-height: 20px; } .menu-item-card { cursor: move; background: #fff; } .menu-item-card:hover { background: #f8f9fa; } /* Indentación visual para subniveles */ .nested-list { margin-left: 25px; border-left: 2px dashed #ddd; padding-left: 10px; margin-top: 5px; } .sortable-ghost { opacity: 0.4; background: #e2e6ea; border: 1px dashed #999; } </style> @endsection
admin/partials/item.blade.php
<li class="list-group-item menu-item-card border mb-2 rounded" data-id="{{ $item->id }}">
<div class="d-flex justify-content-between align-items-center py-1">
<div>
<i class="bi bi-grip-vertical text-muted me-2"></i>
<span class="fw-bold">{{ $item->label }}</span>
<small class="text-muted ms-2 fst-italic">
({{ $item->menuable_type ? class_basename($item->menuable_type) : 'Link' }})
</small>
</div>
<div>
<button class="btn btn-sm btn-link text-decoration-none"
data-bs-toggle="modal" data-bs-target="#editModal"
data-id="{{ $item->id }}"
data-label="{{ $item->label }}"
data-url="{{ $item->url }}"
data-target="{{ $item->target }}">
<i class="bi bi-pencil"></i>
</button>
<form action="{{ route('admin.menu.destroy', $item->id) }}" method="POST" class="d-inline" onsubmit="return confirm('¿Borrar?');">
@csrf @method('DELETE')
<button class="btn btn-sm btn-link text-danger"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
<ul class="list-group sortable-list nested-list">
@foreach($item->children as $child)
@include('admin.menu.partial_item', ['item' => $child])
@endforeach
</ul>
</li>
admin/partials/message.blade.php
@if (session('successMessage'))
<div class="alert alert-success">
{{ session('successMessage') }}
</div>
@endif
@if (session('errorMessage'))
<div class="alert alert-danger">
{{ session('errorMessage') }}
</div>
@endif
@if ($errors->any())
<div class="alert alert-danger">
<ul>
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
ViewComposer: MenuComposerServiceProvider
ViewComposer: MenuComposerServiceProvider
Si ya generaste el provider con php artisan make:provider ViewComposerServiceProvider, Artisan lo agrega automáticamente a bootstrap/providers.php. Si no, hazlo manual.
Paso 1: Generar (si no lo hiciste)
Bash
php artisan make:provider MenuComposerServiceProvider
- Esto crea el archivo y lo registra solo en bootstrap/providers.php.
Paso 2: Editar el código
Abre app/Providers/ViewComposerServiceProvider.php y pega el código del ViewComposer (el que te di antes).
Paso 3: Verificar registro
Abre bootstrap/providers.php (créalo si no existe). Debe verse así:
PHP
return [
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
// ... otros que vengan por defecto
App\Providers\MenuComposerServiceProvider::class, // ¡Este se agrega auto!
];
Implementación
namespace App\Providers;
use App\Models\Menu;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class MenuComposerServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
//
}
/**
* Bootstrap services.
*/
public function boot(): void
{
View::composer('*', function ($view) {
// Carga solo menús padres con sus hijos directos
$view->with([
'_menu' => Menu::whereNull('parent_id')->with('children')->orderBy('order')->get()
]);
});
}
}
El Menú de navegación dinámico
Finalmente implementamos nuestro menu de navegación en el layout
<ul class="navbar-nav mx-auto">
@foreach ($_menu as $item)
@if ($item->children->isEmpty())
{{-- CASO 1: ENLACE SIMPLE (Sin hijos) --}}
<li class="nav-item">
<a class="nav-link {{ request()->url() == $item->href ? 'active fw-bold' : '' }}"
href="{{ $item->href }}" target="{{ $item->target }}">
{{ $item->label }}
</a>
</li>
@else
{{-- CASO 2: DROPDOWN (Con hijos) --}}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button"
data-bs-toggle="dropdown">
{{ $item->label }}
</a>
<ul class="dropdown-menu">
@foreach ($item->children as $child)
<li>
<a class="dropdown-item {{ request()->url() == $child->href ? 'active' : '' }}"
href="{{ $child->href }}" target="{{ $child->target }}">
{{ $child->label }}
</a>
</li>
@endforeach
</ul>
</li>
@endif
@endforeach
</ul>
Archivos que incluyen en la descarga
Archivos que incluyen en la descarga
Los archivos del Menumanager en Laravel que se incluyen en la descarga son los esenciales para integrarlo en un proyecto Laravel
- app
- database
- resources
- routes
Leido 233 veces | 2 usuarios
MenuManager en Laravel
Accede al código fuente esencial de nuestra aplicación en formato ZIP ó TXT. Ideal para desarrolladores que desean personalizar o integrar nuestra solución.
- [ Pago: Paypal ]
- [ Precio: USD 7.00 ]
- [ Descargas: 3 ]
CÓDIGO FUENTE: USD 7.00
Conversar con J.Luis
