Preparación para la entrevista técnica

Laboral Group · Test 001 Software Developer Realizado · 07/05/2026

Este documento es tu guía completa para revisar antes de la entrevista presencial. Incluye qué hiciste, por qué lo hiciste así, las mejoras que mencionarás si te preguntan, y un banco de preguntas probables con respuestas. Léelo entero esta noche y mañana releélo rápido antes de entrar.

1 · Resumen ejecutivo

3Tareas requeridas
4Endpoints en api.php
6Campos por usuario

Qué pedía el test

Una mini-aplicación de gestión de usuarios con:

  1. Listado de usuarios con búsqueda y paginación de 10 en 10.
  2. Modificar usuario al pulsar sobre él (formulario en modal/página).
  3. Nuevo usuario mediante un botón en la cabecera.

Stack obligatorio: Bootstrap + jQuery + AJAX + PHP devolviendo JSON + MySQL. Todo testeado en XAMPP.

Qué entregaste

  • Template SB Admin 2 (gratuito, Bootstrap 4) personalizado en tables.html.
  • Cliente jQuery con AJAX en js/usuarios.js (listar, paginar, buscar, crear, editar).
  • Backend PHP en php/api.php con un único endpoint y parámetro action.
  • BBDD MySQL pruebatecnica · tabla users.
  • Validación de DNI español (8 dígitos + letra) en servidor.
  • Validación de campos obligatorios y longitudes mínimas.
  • Buscador en tiempo real sobre nombre, email, DNI y teléfono.
  • Selector de avatar (4 fotos predefinidas) en los modales de crear/editar.

2 · Requisitos del PDF (palabra por palabra)

Tarea 1 · Listado de Usuarios

Listado con campos: DNI, nombre completo, fecha nacimiento, teléfono y email.

  • Paginación (carga de 10 en 10).
  • Buscador.
  • Al seleccionar un usuario → abrir formulario de modificación.
  • En la parte superior, botón "Nuevo Usuario".

Tarea 2 · Modificar Usuario

  • Al pulsar un usuario → formulario con sus datos.
  • Obligatorios: DNI, nombre completo y fecha de nacimiento.
  • Tener en cuenta longitudes fijas: DNI, fecha y teléfono.

Tarea 3 · Nuevo Usuario

  • Botón en el listado → formulario para introducir datos nuevos y guardar en BBDD.
  • Mismas reglas de obligatoriedad y longitudes que en Modificar.

Metodología impuesta

  • Front-end: Bootstrap (template a descargar del apartado Material).
  • Back-end: AJAX con jQuery → llamada a PHP que devuelve JSON (estilo "API rest").
  • Render: jQuery/JavaScript parsea el JSON y dibuja la información.
  • BBDD: MySQL, tabla obligatoria users.
  • Testing local: XAMPP (Apache + PHP + MySQL).

3 · Stack utilizado

PHP 8 (mysqli) MySQL 8 jQuery 3.x Bootstrap 4 (SB Admin 2) Font Awesome 5 DataTables (incluido por template, no usado) XAMPP local

El stack es el exacto que pedía la prueba. No hay framework moderno (React, Laravel...) porque el enunciado lo prohíbe implícitamente: pedía AJAX con jQuery directo contra PHP devolviendo JSON.

4 · Arquitectura de la solución

┌─────────────────────────────────────────────────────────────────┐ │ NAVEGADOR │ │ │ │ tables.html ──┐ │ │ ├──► usuarios.js (jQuery) │ │ modales │ │ │ │ búsqueda │ │ $.ajax POST │ │ paginación ◄──┘ │ │ │ ▼ │ └──────────────────────────┼──────────────────────────────────────┘ │ action=listar|obtener|crear|actualizar ▼ ┌─────────────────────────────────────────────────────────────────┐ │ APACHE + PHP │ │ │ │ php/api.php (single endpoint, switch action) │ │ │ │ │ │ SQL (mysqli) │ │ ▼ │ └──────────────────────────┼──────────────────────────────────────┘ ▼ ┌──────────────────────┐ │ MySQL │ │ db: pruebatecnica │ │ tabla: users │ └──────────────────────┘

Patrón aplicado

  • Cliente "SPA-light": una sola página HTML (tables.html) que nunca se recarga; todos los cambios entran por AJAX.
  • Backend tipo "controlador único": api.php recibe un parámetro action y enruta internamente. Es el equivalente "casero" a un router REST.
  • Formato de intercambio: JSON en ambas direcciones (request por application/x-www-form-urlencoded al ser $.ajax POST, response con Content-Type: application/json).

5 · Esquema de base de datos

La tabla que da soporte a toda la aplicación:

CREATE DATABASE IF NOT EXISTS pruebatecnica
    CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE pruebatecnica;

CREATE TABLE users (
    id               INT AUTO_INCREMENT PRIMARY KEY,
    nombre           VARCHAR(150) NOT NULL,
    dni              VARCHAR(9)   NOT NULL,
    fecha_nacimiento DATE         NOT NULL,
    telefono         VARCHAR(20)  DEFAULT NULL,
    email            VARCHAR(150) DEFAULT NULL,
    foto             VARCHAR(100) DEFAULT 'undraw_profile.svg',
    UNIQUE KEY uniq_dni (dni)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Si te preguntan por qué VARCHAR(9) en DNI: porque el DNI español tiene exactamente 8 dígitos + 1 letra = 9 caracteres. Es un campo de longitud fija, y al ponerlo UNIQUE garantizamos a nivel de BBDD que no haya duplicados (defensa en profundidad).
Posible pregunta: ¿por qué no CHAR(9) si es longitud fija? Respuesta: la diferencia de rendimiento entre CHAR y VARCHAR para campos tan pequeños es despreciable en InnoDB, y VARCHAR es más estándar. Si insistieran, CHAR(9) sería marginalmente mejor.

6.1 · tables.html · estructura

Tomé el archivo tables.html original del template SB Admin 2 y reemplacé la tabla de ejemplo por la mía. Las piezas clave que añadí:

Buscador y botón "Nuevo Usuario"

<div class="d-sm-flex align-items-center justify-content-between mb-4">
    <h1 class="h3 mb-0 text-gray-800">Listado de Usuarios</h1>
    <button id="btnNuevoUsuario" class="btn btn-primary btn-icon-split">
        <span class="icon text-white-50"><i class="fas fa-plus"></i></span>
        <span class="text">Nuevo Usuario</span>
    </button>
</div>

<div class="row mb-4">
    <div class="col-12 col-md-6">
        <input type="text" id="inputBuscar" class="form-control"
               placeholder="Buscar por DNI, nombre, email o teléfono...">
    </div>
</div>

Tabla vacía + contenedor de paginación

<table class="table table-bordered" id="tablaUsuarios">
    <thead>
        <tr>
            <th>#</th><th>Nombre Completo</th><th>DNI</th>
            <th>Fecha Nacimiento</th><th>Teléfono</th>
            <th>Email</th><th>Acciones</th>
        </tr>
    </thead>
    <tbody><!-- Las filas se cargan con AJAX --></tbody>
</table>
<div id="paginacion"></div>

Modales de Crear / Editar (mismo patrón)

  • Bootstrap 4 modal con data-toggle="modal".
  • Selector de avatar con radio buttons que envían el nombre del SVG.
  • Campos con required, minlength y maxlength HTML5 (primera línea de defensa cliente).
  • Para editar, hay un <input type="hidden" id="edit_id"> que guarda el ID.
Punto a destacar en la entrevista: reutilicé el mismo HTML para crear y editar (dos modales separados pero idéntico esquema). En un refactor lo unificaría en un solo modal cuyo estado lo gestione JavaScript.

6.2 · js/usuarios.js · cliente jQuery

El archivo orquesta toda la interacción. Lo encapsulé en $(document).ready para evitar contaminar el ámbito global y para asegurarme de que el DOM existe.

Estado mínimo

let paginaActual = 1;
let terminoBusqueda = '';

Sólo dos variables porque la fuente de verdad es la base de datos. No mantengo el array de usuarios en memoria (cada cambio fuerza un re-fetch). Es simple y robusto a costa de algunas requests extras.

Función central: cargarUsuarios(page)

function cargarUsuarios(page = 1) {
    paginaActual = page;
    $.ajax({
        url: 'php/api.php',
        type: 'POST',
        data: { action: 'listar', page: page, search: terminoBusqueda },
        dataType: 'json',
        success: function(respuesta) {
            let filas = '';
            respuesta.data.forEach(function(usuario, index) {
                const numeroFila = (paginaActual - 1) * 10 + index + 1;
                filas += `<tr>...</tr>`;
            });
            $('#tablaUsuarios tbody').html(filas);
            generarPaginacion(respuesta.total_pages, paginaActual);
        }
    });
}

Event delegation

Los botones de la tabla y de la paginación se crean dinámicamente. Por eso enlazo eventos a document con delegación, no directamente al botón:

$(document).on('click', '.btn-editar', function() { ... });
$(document).on('click', '#btnAnterior', function() { ... });
$(document).on('click', '#btnSiguiente', function() { ... });
Concepto clave para la entrevista — Event Delegation: Si haces $('.btn-editar').click(...) directamente, sólo se enganchan los botones que existen en ese instante. Cuando AJAX repinta la tabla, los nuevos botones no responden. Por eso engancho al contenedor (document) y filtro con el selector. Esto se llama delegación de eventos.

Buscador en tiempo real

$('#inputBuscar').on('keyup', function() {
    terminoBusqueda = $(this).val().trim();
    cargarUsuarios(1);
});
Mejora honesta que admitir: esto dispara una petición por cada tecla. Lo correcto sería un debounce de ~300 ms para esperar a que el usuario pare de teclear. Lo verás en la sección 10.

6.3 · php/api.php · backend

Patrón front controller casero: un único archivo, un parámetro action, un if/else if que enruta a cada operación.

Cabecera y conexión

header('Content-Type: application/json; charset=utf-8');

$conn = new mysqli('localhost', 'root', '', 'pruebatecnica');
$conn->set_charset("utf8mb4");

$action = isset($_POST['action']) ? $_POST['action'] : '';
  • UTF-8 tanto en cabecera como en la conexión (importante para tildes/ñ).
  • mysqli en vez de PDO: válido para la prueba, pero PDO sería más portable.

Validación de DNI español

function esDniValido($dni) {
    $dni = strtoupper(trim($dni));
    if (strlen($dni) !== 9) return false;
    if (!preg_match('/^[0-9]{8}[A-Z]$/', $dni)) return false;
    return true;
}
Mejora que puedes ofrecer si te lo preguntan: el DNI español tiene una letra calculable a partir de los 8 dígitos (módulo 23 sobre la tabla TRWAGMYFPDXBNJZSQVHLCKE). La regex de arriba sólo valida el formato, no la letra real. Una validación completa sería:
function letraDni(int $numero): string {
    $letras = 'TRWAGMYFPDXBNJZSQVHLCKE';
    return $letras[$numero % 23];
}
function esDniValido(string $dni): bool {
    $dni = strtoupper(trim($dni));
    if (!preg_match('/^(\d{8})([A-Z])$/', $dni, $m)) return false;
    return letraDni((int)$m[1]) === $m[2];
}

Acción listar · paginación + búsqueda

$page   = isset($_POST['page']) ? (int)$_POST['page'] : 1;
$limit  = 10;
$offset = ($page - 1) * $limit;
$search = trim($_POST['search'] ?? '');

$where = '';
if ($search !== '') {
    $searchEsc = $conn->real_escape_string($search);
    $where = "WHERE nombre LIKE '%$searchEsc%' OR email LIKE '%$searchEsc%'
              OR dni LIKE '%$searchEsc%' OR telefono LIKE '%$searchEsc%'";
}

$totalRegistros = $conn->query("SELECT COUNT(*) as total FROM users $where")
                       ->fetch_assoc()['total'];
$totalPaginas   = ceil($totalRegistros / $limit);

$sql = "SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
        FROM users $where ORDER BY id DESC LIMIT $offset, $limit";
$result = $conn->query($sql);
  • Casteo (int)$_POST['page'] antes de inyectarlo — protección contra SQL injection en ese campo.
  • real_escape_string para el término de búsqueda.
  • Hago dos queries: una para el total (paginación) y otra para los 10 registros visibles.

Respuesta JSON estándar

echo json_encode([
    'data'        => $usuarios,
    'total'       => $totalRegistros,
    'page'        => $page,
    'total_pages' => $totalPaginas
]);

Acciones crear y actualizar

Idénticas salvo por el SQL (INSERT vs UPDATE). Ambas:

  1. Comprueban que nombre, dni y fecha_nacimiento no están vacíos.
  2. Validan formato de DNI.
  3. Si hay teléfono, comprueban longitud mínima de 9.
  4. Escapan cada string con real_escape_string.
  5. Devuelven {success:true, message:'...'} o {error:'...'}.

7 · Decisiones técnicas y por qué

¿Por qué un único api.php con action en vez de varios archivos?
Para mantener la lógica de conexión y la cabecera Content-Type centralizadas en un solo sitio (DRY). Hacer list.php, create.php, update.php habría duplicado código. En un proyecto real usaría un router (Slim, Symfony) o reescritura URL, pero para una prueba esto es lo más claro y rápido.
¿Por qué POST para listar en vez de GET?
Honestamente, fue por uniformidad: así todas las llamadas usan el mismo patrón POST + action. Lo correcto en una API REST es GET para lecturas (idempotentes, cacheables) y POST/PUT/DELETE para escrituras. Si me lo preguntan, lo digo tal cual: "Lo simplifiqué a un solo verbo; en REST puro usaría GET /users?page=2&search=...".
¿Por qué SB Admin 2?
El PDF pedía expresamente "podéis descargar un template de bootstrap desde el apartado Material". SB Admin 2 es uno de los más usados (gratuito, Bootstrap 4, MIT), tiene tabla, sidebar, modales y formularios ya estilados. Me permitió centrarme en la lógica del backend en lugar de pelearme con CSS.
¿Por qué jQuery y no Fetch/Vanilla JS moderno?
Porque el enunciado lo exige textualmente: "se empleará ajax con jquery". Si me lo permiten, hoy lo haría con fetch() y async/await, que es nativo y no requiere librería externa.
¿Por qué validas en cliente Y en servidor?
Regla de oro: el cliente valida para UX (feedback inmediato), el servidor valida para seguridad. Un atacante puede saltarse el HTML/JS con Postman o curl, así que la validación real siempre está en el backend. El required y minlength del HTML son sólo una conveniencia.
¿Por qué avatares fijos en vez de subida de fotos?
La prueba no pedía gestión de ficheros, y subir imágenes implica validar MIME-type, tamaño, seguridad contra path traversal, etc. Con 4 SVGs predefinidos cumplo visualmente sin abrir esa lata de complejidad. Si me lo piden, sé cómo hacerlo con $_FILES y move_uploaded_file.
¿Por qué ORDER BY id DESC?
Para que los usuarios recién creados aparezcan arriba. Es la UX esperada cuando acabas de añadir alguien (lo ves inmediatamente). Como id es AUTO_INCREMENT, DESC = orden inverso de creación.

8 · Validaciones implementadas

CampoCliente (HTML)Servidor (PHP)
Nombre required empty($nombre) + real_escape_string
DNI required minlength=9 maxlength=9 Regex /^[0-9]{8}[A-Z]$/
Fecha nacimiento type=date required empty($fecha_nacimiento) (formato lo garantiza el input date)
Teléfono minlength=9 maxlength=20 Si no está vacío → strlen < 9 rechaza
Email type=email (no validado explícitamente — mejora pendiente)
Foto Radio button preseleccionado Fallback a undraw_profile.svg si llega vacío
Lagunas que reconoceré honestamente en la entrevista:
  • El email no se valida server-side. filter_var($email, FILTER_VALIDATE_EMAIL) sería el fix.
  • El DNI sólo se valida en formato, no la letra de control.
  • La fecha no comprueba que sea anterior a hoy ni rango plausible (1900<año<hoy).
  • No hay control de DNI duplicado: si lo añado, devolveré 409 Conflict.

9 · Seguridad · vulnerabilidades y cómo las cerraría

completamente honesto en este punto si te preguntan. Reconocer fallos y proponer la solución vale más que pretender que el código es perfecto. Demuestra madurez técnica.

9.1 · SQL Injection · riesgo bajo pero existe

Uso real_escape_string, lo cual protege, pero no es la mejor práctica. Lo correcto son prepared statements:

// Lo que hago ahora
$nombreEsc = $conn->real_escape_string($nombre);
$sql = "INSERT INTO users (nombre, dni, ...) VALUES ('$nombreEsc', ...)";

// Lo que debería hacer
$stmt = $conn->prepare("INSERT INTO users (nombre, dni, fecha_nacimiento, telefono, email, foto)
                        VALUES (?, ?, ?, ?, ?, ?)");
$stmt->bind_param('ssssss', $nombre, $dni, $fechaNacimiento, $telefono, $email, $foto);
$stmt->execute();

Por qué importa: con prepared statements el driver envía los valores por separado del SQL, por lo que nunca se pueden interpretar como sintaxis. real_escape_string depende de que no se olvide nunca y de que el charset esté bien configurado.

9.2 · XSS · vulnerabilidad real en el frontend

En usuarios.js concateno el HTML así:

filas += `<td>${usuario.nombre}</td>`;

Si un usuario malicioso (o un compañero) crea uno con nombre <script>alert(1)</script>, el script se ejecuta en el navegador de todos los que vean el listado.

Fix: escapar antes de insertar:

function escapeHtml(s) {
    return String(s ?? '').replace(/[&<>"']/g, c => ({
        '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
    }[c]));
}
// uso:
filas += `<td>${escapeHtml(usuario.nombre)}</td>`;

O usar $.text() en vez de $.html(), o construir nodos con document.createElement.

9.3 · CSRF · no hay token

Cualquier página externa podría hacer POST a php/api.php con cookies del usuario. Fix: token CSRF en sesión, enviado en cada AJAX por header X-CSRF-Token y verificado en PHP.

9.4 · No hay autenticación

Cualquiera con acceso a la URL puede listar, editar y borrar. La prueba no lo pedía, pero en producción haría:

  • Login con sesiones PHP o JWT.
  • Verificación de sesión al inicio de api.php antes de procesar la acción.
  • Roles (admin/usuario) si fueran necesarios.

9.5 · Credenciales hardcodeadas

$username = 'root';
$password = '';

Problema: si subo esto a Git con credenciales reales, fuga. Fix: variables de entorno (getenv('DB_PASS')) o un archivo config.php excluido por .gitignore.

9.6 · Errores genéricos

Hoy devuelvo {"error":"Error creating user"} sin detalle. Bien para no filtrar información a un atacante, pero malo para debug. Solución: loguear el error real (error_log) y devolver un mensaje genérico al cliente.

9.7 · Cierre rápido del checklist OWASP Top 10

OWASPEstadoMitigación
A01 Broken Access ControlvulnerableAñadir auth
A03 InjectionparcialPrepared statements
A05 MisconfigurationparcialQuitar credenciales hardcodeadas
A07 Auth FailuresvulnerableLogin obligatorio
XSS (era A07 en 2017)vulnerableEscape en render

10 · Optimización y mejoras técnicas

Hoy

Cada tecla en el buscador → 1 petición HTTP.

Si tecleo "antonio" → 7 peticiones, las 6 primeras se desperdician.

Con debounce

Espero 300 ms tras la última tecla y disparo una sola petición.

function debounce(fn, ms = 300) {
    let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
}
$('#inputBuscar').on('keyup', debounce(function() {
    terminoBusqueda = $(this).val().trim();
    cargarUsuarios(1);
}, 300));

10.2 · Índices en BBDD para el buscador

El LIKE '%texto%' impide usar índices B-tree (porque empieza por comodín). Para tablas pequeñas no importa, pero a partir de ~50k registros se nota. Soluciones:

  • Fulltext index: ALTER TABLE users ADD FULLTEXT(nombre, email) + MATCH(...) AGAINST(...).
  • Migrar a buscador externo: Elasticsearch / Meilisearch para fuzzy search.

10.3 · Loaders y notificaciones decentes

Hoy uso alert(), que bloquea el hilo y es feísimo. Mejora:

// Mostrar spinner mientras carga
$('#tablaUsuarios tbody').html('<tr><td colspan="7" class="text-center"><i class="fa fa-spinner fa-spin"></i> Cargando...</td></tr>');

// En vez de alert(), un toast (SB Admin 2 trae Bootstrap toast):
function toast(msg, type='success') {
    $('#toastContainer').append(`<div class="toast bg-${type}">${msg}</div>`);
}

10.4 · Validación reactiva en el formulario

Hoy el usuario sólo descubre que el DNI es inválido al enviar. Lo ideal es validar on blur del campo y marcar en rojo en cuanto pierde foco.

10.5 · Paginación más rica

Hoy sólo tengo "Anterior / Siguiente". En 30 segundos puedo añadir botones numerados:

function generarPaginacion(total, actual) {
    let html = `<button id="btnAnterior" ${actual<=1?'disabled':''}>«</button>`;
    for (let i = 1; i <= total; i++) {
        html += `<button class="btn-page ${i===actual?'active':''}" data-page="${i}">${i}</button>`;
    }
    html += `<button id="btnSiguiente" ${actual>=total?'disabled':''}>»</button>`;
    $('#paginacion').html(html);
}

10.6 · Una sola query para listar + total

Hoy hago dos queries: COUNT(*) y SELECT. En MySQL puedo combinarlas con SQL_CALC_FOUND_ROWS (legacy) o con una subquery, aunque para tablas pequeñas la diferencia es despreciable y la legibilidad del código actual es mejor.

10.7 · Caching del listado

Para tablas con muchos hits y pocos cambios, cachear el JSON en Redis con TTL de 30s reduce drásticamente las queries.

11 · Cómo lo refactorizaría hoy (versión moderna)

11.1 · Backend con prepared statements + PDO

// db.php
function db(): PDO {
    static $pdo = null;
    if ($pdo === null) {
        $pdo = new PDO(
            'mysql:host=localhost;dbname=pruebatecnica;charset=utf8mb4',
            getenv('DB_USER') ?: 'root',
            getenv('DB_PASS') ?: '',
            [
                PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                PDO::ATTR_EMULATE_PREPARES   => false,
            ]
        );
    }
    return $pdo;
}

// ListUsers.php
function listUsers(int $page, string $search): array {
    $limit = 10; $offset = ($page - 1) * $limit;
    $params = [];
    $where = '';
    if ($search !== '') {
        $where = "WHERE nombre LIKE :s OR email LIKE :s OR dni LIKE :s OR telefono LIKE :s";
        $params[':s'] = "%$search%";
    }
    $total = db()->prepare("SELECT COUNT(*) FROM users $where");
    $total->execute($params);
    $totalRows = (int) $total->fetchColumn();

    $stmt = db()->prepare("SELECT id, nombre, dni, fecha_nacimiento, telefono, email, foto
                          FROM users $where ORDER BY id DESC LIMIT :limit OFFSET :offset");
    foreach ($params as $k => $v) $stmt->bindValue($k, $v);
    $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
    $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
    $stmt->execute();

    return ['data' => $stmt->fetchAll(), 'total' => $totalRows,
            'page' => $page, 'total_pages' => (int) ceil($totalRows / $limit)];
}

11.2 · Cliente con fetch + async/await (sin jQuery)

async function cargarUsuarios(page = 1) {
    const params = new URLSearchParams({ action: 'listar', page, search: terminoBusqueda });
    const res = await fetch('php/api.php', { method: 'POST', body: params });
    const data = await res.json();
    if (data.error) return mostrarError(data.error);
    renderTabla(data.data);
    renderPaginacion(data.total_pages, data.page);
}

11.3 · REST verbos correctos

OperaciónHoyREST correcto
ListarPOST api.php action=listarGET /api/users?page=1&q=...
DetallePOST api.php action=obtenerGET /api/users/{id}
CrearPOST api.php action=crearPOST /api/users
EditarPOST api.php action=actualizarPUT /api/users/{id}
Borrar(no implementado)DELETE /api/users/{id}

11.4 · Stack moderno alternativo

  • Backend: Laravel/Lumen, Symfony, Slim, o Node.js (Express/Fastify) si TypeScript.
  • Frontend: Vue 3 o React + TypeScript, con un componente UserList y UserForm.
  • Estado: React Query / TanStack Query para cache, refetch automático y optimistic updates.
  • UI: Mantine, shadcn/ui o Material — más profesional que un template estático.
  • BBDD: sigue siendo MySQL o PostgreSQL. ORM (Eloquent/Prisma) para tipado.

12 · Banco de preguntas probables y respuestas

Cuéntame qué has hecho en la prueba.
Una mini-app de gestión de usuarios con tres operaciones: listar (con paginación y búsqueda), crear y editar. El front es Bootstrap (SB Admin 2) con jQuery y AJAX, el backend es PHP devolviendo JSON, y la persistencia es MySQL. Todo testado en XAMPP en local. La tabla principal es users con id, nombre, dni, fecha_nacimiento, telefono, email, foto.
¿Qué es AJAX?
Asynchronous JavaScript and XML. Es la técnica que permite al navegador hacer peticiones HTTP al servidor sin recargar la página. Hoy el XML es anecdótico: usamos JSON. En jQuery se hace con $.ajax; nativamente con fetch() o XMLHttpRequest.
¿Qué es REST?
Representational State Transfer. Es un estilo arquitectónico para diseñar APIs que se apoya en HTTP y sus verbos (GET, POST, PUT, DELETE, PATCH). Cada recurso tiene una URL única (/users/42) y el verbo define la operación. Es stateless: cada petición lleva toda la info necesaria.
¿Cómo evitas inyección SQL?
En la prueba uso real_escape_string y casteo a (int) los IDs/páginas. La mejor práctica son prepared statements (PDO o mysqli con bind_param), porque separan el SQL de los datos en el protocolo del driver. Es lo que pondría en producción.
¿Qué es XSS y cómo lo previenes?
Cross-Site Scripting: meter código JavaScript dentro de datos que luego el navegador interpreta. Se previene escapando cualquier dato del usuario antes de meterlo en HTML (&&amp;, <&lt;, etc.). En jQuery, usar .text() en vez de .html(). Reconozco que en mi prueba inyecto los nombres con plantilla literal, lo cual es vulnerable — lo corregiría con una función escapeHtml.
¿Qué diferencia hay entre POST y GET?
GET es para leer (idempotente, cacheable, parámetros visibles en la URL). POST es para crear o modificar (no idempotente por defecto, body separado, no cacheable). En mi prueba uso POST para todo por uniformidad, pero en REST GET sería el verbo correcto para listar.
¿Qué hace $(document).ready() y por qué?
Espera a que el DOM esté completamente parseado antes de ejecutar el código. Sin él, si el script está antes del HTML que manipula, fallaría porque los elementos aún no existen. Hoy en navegadores modernos basta con poner el <script> al final del body o con defer.
¿Qué es event delegation?
En vez de enganchar un evento a un elemento concreto, lo enganchas a un contenedor padre estable y filtras por selector. Así, los elementos añadidos dinámicamente (por AJAX) también responden. En la prueba lo uso para los botones .btn-editar y los de paginación, que se recrean con cada $.html().
¿Cómo escalarías esto a 1 millón de usuarios?
  1. Migrar a prepared statements y validación robusta (sí o sí).
  2. Índice en dni y un índice fulltext en nombre, email para el buscador.
  3. Paginación por cursor (WHERE id < ?) en vez de OFFSET, que se vuelve lento con páginas profundas.
  4. Cache de Redis para las queries más comunes.
  5. Compresión gzip + HTTP/2.
  6. Servidor PHP en un pool con balanceador (HAProxy/Nginx) y MySQL en réplica master/slave para escalar lecturas.
  7. Buscador externo (Meilisearch / Elastic) si el LIKE deja de rendir.
¿Por qué utf8mb4 y no utf8?
El utf8 de MySQL es un alias engañoso: sólo soporta hasta 3 bytes por carácter, por lo que no soporta emojis ni algunos caracteres chinos. utf8mb4 es el UTF-8 real, hasta 4 bytes. Siempre utf8mb4.
¿Cómo testarías esto?
  • Backend: PHPUnit. Test unitarios para esDniValido() (con casos buenos y malos), tests de integración contra una BBDD de test que se reinicia con fixtures.
  • Frontend: Jest + Testing Library, o Cypress para tests end-to-end (login, crear usuario, buscar, paginar).
  • API: Postman collections o tests con guzzlehttp/guzzle en PHP.
¿Y si te pidiera añadir "Borrar usuario" ahora mismo?
  1. Añado un botón btn-borrar en cada fila con data-id.
  2. Listener delegado al document con confirmación (SweetAlert idealmente, confirm() mínimo).
  3. Nueva acción borrar en api.php con DELETE FROM users WHERE id = ? (prepared).
  4. Tras éxito, recargo el listado (cargarUsuarios(paginaActual)).
  5. Plus: soft delete con una columna deleted_at en vez de borrar físico, para poder restaurar.
¿Diferencia entre mysqli y PDO?
mysqli sólo habla MySQL. PDO es una abstracción sobre múltiples drivers (MySQL, PostgreSQL, SQLite...). PDO es más portable y tiene una API más limpia para prepared statements. mysqli ofrece algunos métodos específicos de MySQL ligeramente más rápidos. En proyectos nuevos suelo usar PDO.
¿Por qué Bootstrap 4 y no Bootstrap 5 o Tailwind?
El template gratuito SB Admin 2 estaba en Bootstrap 4 y ya tenía sidebar, tabla y modales listos. Para una prueba contrarreloj, era la elección con mejor ratio resultado/tiempo. En un proyecto nuevo iría con Bootstrap 5 (que ya no necesita jQuery) o Tailwind si necesito UI muy custom.
¿Conoces TypeScript?
Sí. TS añade tipado estático sobre JavaScript, lo cual detecta errores en compilación en vez de en runtime. Hoy es prácticamente el estándar para proyectos serios en React/Vue. Si refactorizase la prueba en JS moderno, lo haría en TS y definiría una interface Usuario compartida cliente-servidor (con OpenAPI o un generador).
Háblame de Git.
Uso Git diariamente. Flujo habitual: feature branches, commits atómicos con mensajes claros (Conventional Commits si el equipo lo usa), pull requests con review, merge a main. En la prueba no inicialicé repo, pero en un proyecto real haría git init desde el minuto cero, con un .gitignore que excluya vendor/, node_modules/ y archivos con credenciales.
¿Qué es CORS y cuándo aparece?
Cross-Origin Resource Sharing. Es la política del navegador que impide que JavaScript de un dominio haga peticiones AJAX a otro dominio salvo que el servidor lo autorice con cabeceras Access-Control-Allow-Origin. En mi prueba no hay CORS porque cliente y servidor están en el mismo origen (localhost). Aparecería si separase el front en otro dominio.

13 · Cómo presentar el código en vivo

Si te piden enseñar la aplicación, lleva este guion mental para no improvisar.
  1. Arrancar XAMPP: Apache + MySQL en verde. Abrir http://localhost/pruebatecnica/tables.html.
  2. Mostrar el listado: 10 registros, paginación abajo, búsqueda arriba.
  3. Buscar: teclear "antonio" y comentar: "Lo correcto sería un debounce de 300 ms; lo añadiría en producción".
  4. Paginar: ir a página 2, mostrar que la numeración de la primera columna sigue correcta ((página-1)*10 + index + 1).
  5. Editar: abrir un usuario, cambiar el teléfono, guardar. Mostrar que se actualiza sin recargar.
  6. Crear: intentar guardar con DNI inválido para enseñar la validación.
  7. Abrir DevTools → Network: repetir una acción y mostrar el JSON de ida y vuelta. Esto demuestra que entiendes lo que pasa "por debajo".
  8. Abrir el código: abrir api.php en la sección de validación de DNI y explicar la regex.
  9. Cerrar con una mejora: "Lo siguiente que añadiría sería X" (debounce, prepared statements o auth). Demuestra visión.

14 · Cheatsheet · lo último que repasarás

Archivos modificados

  • tables.html
  • js/usuarios.js
  • php/api.php

BBDD

  • db: pruebatecnica
  • tabla: users
  • charset: utf8mb4

Acciones API

  • listar (page, search)
  • obtener (id)
  • crear (datos)
  • actualizar (id, datos)

Validaciones

  • DNI: regex /^\d{8}[A-Z]$/
  • Obligatorios: nombre, dni, fecha
  • Teléfono mín. 9

3 mejoras que siempre mencionar

  1. Prepared statements (PDO).
  2. Escape XSS en el render.
  3. Debounce del buscador.

3 conceptos que tienes que dominar

  1. Event delegation.
  2. Por qué validar en cliente y servidor.
  3. Idempotencia de los verbos HTTP.
Mentalidad para la entrevista:
  • Si no sabes algo, dilo y explica cómo lo investigarías. Es la mejor respuesta posible.
  • Cuando expliques código, habla del por qué más que del qué.
  • Las mejoras que reconoces voluntariamente antes de que te las señalen suman muchísimo.
  • Pregunta tú también: por el equipo, el stack real, los retos de la posición. Demuestra interés.

Mucha suerte mañana. Estás preparado.

15 · Discurso paso a paso · "Cuéntame qué has hecho"

Si te dicen "explícame en general lo que has hecho", esto es lo que sueltas: 6 bloques de ~2-3 minutos en total. Empieza por el problema (no por el código), enseña que entiendes el flujo de arriba abajo y cierra con mejoras voluntarias. Practícalo una vez en voz alta antes de entrar.
6Bloques del discurso
2-3Minutos de duración
30sVersión ultra-corta

1 · Abre con el problema, no con el código ~15 s

"El test pedía una mini-aplicación de gestión de usuarios con tres operaciones: listar usuarios con paginación y búsqueda, modificarlos al pulsar sobre uno, y crear nuevos desde un botón en la cabecera. El stack venía marcado: Bootstrap en el front, jQuery con AJAX como cliente, PHP devolviendo JSON, MySQL detrás y XAMPP en local."

Esto demuestra que has leído bien el enunciado antes de codificar. Primera señal positiva para el entrevistador.

2 · Cuenta la arquitectura general antes del detalle ~20 s

"Lo planteé como un SPA-light: una sola página HTML, tables.html, que no se recarga nunca. Todos los cambios entran por AJAX contra un único endpoint PHP, api.php, que actúa como front controller: recibe un parámetro action con valores listar, obtener, crear o actualizar y enruta internamente. Por debajo, MySQL con una única tabla users."

Ya has dicho dos conceptos importantes: SPA-light y front controller. Si te paran y preguntan qué significa cualquiera, mejor — controlas la conversación.

3 · Base de datos en una frase ~15 s

"La tabla users tiene seis campos: nombre, dni, fecha_nacimiento, telefono, email y foto, más el id autoincremental. Puse dni como UNIQUE a nivel de BBDD como defensa en profundidad, por si la validación de la aplicación fallara. Charset utf8mb4 para soportar tildes y emojis."

El detalle del utf8mb4 y el UNIQUE son los que se quedan grabados. Si te dicen "¿por qué utf8mb4?", ya tienes la respuesta lista en la sección 12.

4 · Backend · cómo funciona api.php ~30-40 s

"En el backend, api.php empieza fijando la cabecera Content-Type: application/json y abre la conexión mysqli. Lee action de $_POST y entra en un switch lógico.

La acción más interesante es listar: recibe page y search. Casteo page a int para que no me puedan inyectar SQL por ese parámetro, y al término de búsqueda le aplico real_escape_string. Construyo una WHERE dinámica con LIKE sobre nombre, email, DNI y teléfono. Hago dos queries: una para el COUNT(*) (para calcular el total de páginas) y otra para los 10 registros visibles, ordenados por id DESC para que los recién creados aparezcan arriba.

Para crear y actualizar valido server-side los obligatorios (nombre, dni, fecha), el formato del DNI con regex (^\d{8}[A-Z]$) y la longitud mínima del teléfono. La respuesta siempre es JSON con success o error."

Aquí estás contando el flujo, no leyendo código. Eso impresiona más que recitar líneas.

5 · Frontend · cómo orquesta usuarios.js ~30-40 s

"En el cliente, usuarios.js está envuelto en $(document).ready para asegurar que el DOM existe. Mantengo solo dos variables de estado: la página actual y el término de búsqueda. La fuente de verdad es la BBDD: cada cambio dispara un re-fetch, así no me complico sincronizando un estado en memoria.

La función central es cargarUsuarios(page): hace $.ajax POST a api.php con action: 'listar', recibe el JSON, y dibuja las filas concatenando un template literal. Los botones de la tabla y de la paginación se crean dinámicamente, así que uso event delegation: engancho el listener al document y filtro por selector. Si no, los botones nuevos no responderían tras un repintado AJAX.

El buscador dispara una petición en cada keyup. Es un punto que mencionaré en mejoras: lo correcto sería un debounce de 300 ms."

Event delegation y debounce son las dos palabras que tienes que pronunciar. Son rojas en el ojo del entrevistador.

6 · Cierra con mejoras voluntarias ~15-20 s

"Funciona, pero soy consciente de tres mejoras importantes que haría en producción:

Primero, prepared statements con PDO en vez de real_escape_string, porque separan el SQL de los datos en el protocolo del driver y no dependen de que no se te olvide nunca.

Segundo, escapar el HTML antes de inyectarlo en la tabla, porque si un usuario malicioso guardara <script>alert(1)</script> como nombre, se ejecutaría en el navegador de cualquiera que abriera el listado. Es un XSS clásico.

Y tercero, el debounce del buscador que te decía, y validación de email con filter_var(..., FILTER_VALIDATE_EMAIL) que ahora no tengo. ¿Quieres que entremos en alguno de estos puntos?"

Acabar con una pregunta abierta te quita la presión y dirige la conversación a tu terreno. Habrás soltado: SQL injection, XSS, debounce, validación robusta. Todo bien atado.

Tips de entrega

Tono y ritmo

  • Habla más despacio de lo que crees. Los nervios aceleran.
  • Si te oyes pausado, vas bien.
  • Después de cada bloque, respira 1 segundo. Da tiempo al entrevistador a interrumpir si quiere.

Vocabulario que suma

Suelta estas palabras cuando puedas — cada una compra credibilidad:

front controller SPA-light defensa en profundidad idempotente event delegation debounce prepared statements XSS utf8mb4
Si te quedas en blanco · tienes dos comodines que siempre funcionan:
  1. "Déjame mirar el código un momento para no inventarme un detalle." Mejor que decir algo dudoso.
  2. "No estoy 100% seguro de eso. Lo que yo haría sería buscarlo en la documentación oficial." Demuestra honestidad y autonomía.
Si te preguntan algo que no esperabas:
  • Técnico y lo sabes → respondes y vuelves al hilo.
  • Técnico y no lo sabes → "No lo tengo fresco, pero por lo que entiendo es X, lo confirmaría en la doc." Nunca inventes; las mentiras se notan.
  • De equipo / proceso → cuéntalo en presente con un ejemplo concreto.

Versión ultra-corta · 30 segundos

Si en algún momento te piden el resumen rápido (pasilleo, presentación inicial, "en una frase"...):

"Una mini-app de usuarios con listado paginado, búsqueda en tiempo real, crear y editar. Frontend en tables.html con Bootstrap (SB Admin 2) y usuarios.js con jQuery + AJAX. Backend en api.php, un único endpoint con parámetro action que enruta a cada operación y devuelve JSON. MySQL con tabla users y dni único. Validación cliente y servidor del DNI español. Mejoras que haría: prepared statements, escape XSS al pintar, y debounce en el buscador."

Esa versión la puedes recitar de memoria. Las mejoras siempre al final.

El truco final · describe el viaje del dato

No describas el código línea a línea. Describe el viaje del dato. Esta frase, dicha entera, te coloca como alguien que entiende el sistema, no solo el código. Es la diferencia entre un junior que sabe sintaxis y un developer con criterio.
┌────────────────────────────────────────────────────────────────────┐ │ EL VIAJE DEL DATO · UNA FRASE │ └────────────────────────────────────────────────────────────────────┘ [1] El usuario teclea en el buscador │ ▼ [2] Evento keyup en jQuery │ ▼ [3] AJAX $.ajax POST hacia php/api.php │ data: { action, page, search } ▼ [4] PHP recibe action y search, valida y enruta │ ▼ [5] MySQL ejecuta SELECT ... LIKE ... y devuelve filas │ ▼ [6] PHP serializa con json_encode() y responde │ ▼ [7] jQuery recibe JSON, repinta el <tbody> │ ▼ [8] Event delegation engancha los botones nuevos al vuelo

Memoriza el flujo. Si te lo preguntan, lo recitas con seguridad y has demostrado en 20 segundos que entiendes de extremo a extremo qué pasa cuando alguien usa tu aplicación.

Antes de entrar a la entrevista:
  • Léete esta sección entera en voz alta una vez.
  • Practica el bloque 1 y la versión ultra-corta hasta que salgan sin pensar.
  • Repasa los 3 comodines y el vocabulario que suma.
  • Si te bloqueas, tira del "viaje del dato": te saca de cualquier laguna porque siempre puedes contar el flujo.

16 · Mis otros proyectos personales

Por si en la entrevista te preguntan "¿qué más has hecho?" o "¿qué tecnologías has tocado?", aquí tienes los 5 proyectos públicos del portfolio (aleixaj.com) con su stack y una explicación corta de para qué sirve cada herramienta dentro de ese proyecto. Pensado para que puedas hablar con seguridad de cualquiera sin titubear.

Obsidian · e-commerce full-stack de streetwear

Repo: github.com/AleixAj/obsidian Demo: obsidian.aleixaj.com

Tienda React con catálogo servido por una API Laravel 11, autenticación con sesiones-cookie, carrito y wishlist sincronizados entre invitado y usuario, y un checkout básico que crea pedidos reales. El frontend está desplegado en Cloudflare y la API+MySQL en Railway.

React 19 TypeScript Vite 8 React Router 7 TanStack Query 5 React Context CSS + tokens Laravel 11 Sanctum MySQL 8 Cloudflare Workers Railway

Para qué sirve cada una

  • React 19 · librería UI por componentes. Renderiza listados de producto, modales del carrito, drawer, dashboard de cuenta.
  • TypeScript · tipado estático. Modela Product, CartItem, Category y blinda los contratos con la API.
  • Vite 8 · servidor de desarrollo con HMR (recarga en caliente) y bundler para producción.
  • React Router 7 · navegación cliente sin recargar la página (/shop, /product/:slug, /account...).
  • TanStack Query 5 · gestión de estado del servidor: cachea productos, reintenta peticiones fallidas, muestra estados de carga/error y refresca datos sin que tengas que escribirlo a mano.
  • React Context · estado local de UI (carrito en memoria, wishlist guest, toasts). Complementa a Query: Query para datos del servidor, Context para datos del cliente.
  • CSS plano + tokens · estilos sin framework (variables CSS para colores/espaciados), para demostrar fundamentos sin depender de Tailwind/Bootstrap.
  • Laravel 11 · framework PHP del backend. Expone la API REST (/api/products, /api/cart, /api/auth/login...).
  • Sanctum · sistema de autenticación de Laravel basado en cookies SPA + tokens. Resuelve login/register/logout.
  • MySQL 8 · base de datos relacional donde viven productos, usuarios, pedidos, direcciones, carritos y wishlists.
  • Cloudflare Workers + Assets · hosting del frontend SPA en el edge global, con fallback SPA configurado.
  • Railway · hosting gestionado del backend Laravel + MySQL de producción.
Si te preguntan por TanStack Query · es la librería que gestiona el estado de servidor en React. En vez de hacer useEffect + fetch + useState a mano, te da caché compartida entre componentes, deduplicación de peticiones, retries automáticos, estados de carga/error y refetch al volver a la pestaña. Aquí la uso para listar productos, sincronizar el carrito y la wishlist con el backend.

Lord of the Clicks · clicker incremental en la Tierra Media

Repo: github.com/AleixAj/lordoftheclicks Demo: lotrclicker.aleixaj.com

Juego incremental con 30 zonas, combate por clic, semi-jefes y jefes con temporizador, árbol de mejoras, 20 compañeros reclutables y guardado persistente. La lógica del juego vive como TypeScript puro fuera de React, y React solo representa el estado. Tiene tests unitarios con Vitest y CI en GitHub Actions.

Vite 6 React 19 TypeScript 5.7 strict Zustand 5 Tailwind 4 CSS Modules Vitest 3 Testing Library ESLint + jsx-a11y Husky + lint-staged GitHub Actions (CI) Cloudflare Pages pnpm 11

Para qué sirve cada una

  • Vite 6 · build tool con HMR ultra rápido y bundle de producción optimizado.
  • React 19 · capa de UI: paneles de combate, mapa interactivo, modales, drawers.
  • TypeScript strict · modela el dominio del juego (Enemy, Quest, Companion, UpgradeDefinition). El compilador exige tratar todos los casos.
  • Zustand 5 · estado global del cliente. Es como Redux pero sin boilerplate. Aquí guarda oro, mithril, nivel, inventario, misiones y compañeros, con selectors granulares para evitar re-renders.
  • Tailwind 4 · framework CSS de utilidades. Para layouts y espaciado rápido (clases como flex gap-4).
  • CSS Modules · estilos aislados por componente para piezas custom (escena de combate, mapa, retratos, currency bar) que con utilidades sería ilegible.
  • Vitest 3 + Testing Library · framework de tests muy rápido sobre Vite. Cubre 43 tests: fórmulas de combate, progresión, store de Zustand y game loop.
  • ESLint + jsx-a11y · linter con reglas de accesibilidad que bloquean el lint si metes un <div onClick> en vez de <button>.
  • Husky + lint-staged · hooks de Git que ejecutan lint y formato automáticamente antes de cada commit, solo sobre los archivos modificados.
  • GitHub Actions · pipeline de CI/CD: en cada push corre lint, typecheck, tests y build.
  • Cloudflare Pages · hosting estático global con preview por PR.
  • pnpm 11 · gestor de paquetes alternativo a npm; comparte dependencias entre proyectos y es mucho más rápido instalando.
Si te preguntan por Zustand · es una librería de estado global mucho más ligera que Redux (~1 KB). Te permite tener un store compartido entre componentes sin envolver toda la app en providers. Cada componente se suscribe solo al trozo de estado que necesita, así que no re-renderiza cuando cambia algo irrelevante. Aquí lo uso porque el estado del juego es muy denso (cientos de campos) y necesito acceder a piezas concretas desde muchos sitios.

Solar Explorer · Sistema Solar 3D interactivo

Repo: github.com/AleixAj/solar-system Demo: solarsystem.aleixaj.com

Escena 3D en WebGL del Sistema Solar con planetas seleccionables, tour guiado con cámara animada, panel de datos por planeta, control de velocidad temporal y soporte ES/EN. Pensado para crecer con el tiempo, no como demo de una sola página.

React 19 TypeScript Vite Three.js React Three Fiber @react-three/drei Tailwind CSS Cloudflare (Wrangler)

Para qué sirve cada una

  • React 19 · toda la capa DOM: header, drawer móvil, panel de info, modales, tour guiado.
  • TypeScript · modela los datos tipados de planetas y lunas, y el contenido traducible ES/EN.
  • Vite · servidor de desarrollo y bundle.
  • Three.js · librería 3D que se encarga del WebGL "a pelo": escenas, cámaras, materiales, geometrías, luces. Es la base de cualquier 3D en navegador.
  • React Three Fiber · renderer de Three.js dentro de React. En vez de escribir Three.js imperativo (scene.add(mesh)), declaras la escena como JSX (<mesh><sphereGeometry/></mesh>). Esto la hace componible y reactiva al estado de React.
  • @react-three/drei · colección de "helpers" para R3F: cámaras prediseñadas, controles de órbita, helpers de carga de modelos GLB, stars, environment, etc. Te ahorra reimplementar piezas comunes.
  • Tailwind CSS · estilos del DOM (paneles, drawer, modales). El Canvas WebGL no lo estiliza Tailwind, ese vive aparte.
  • Cloudflare + Wrangler · CLI para desplegar el SPA en Cloudflare Workers/Pages.
Pregunta clave que puede venir · "¿Qué diferencia hay entre Three.js y React Three Fiber?". Three.js es la librería base de WebGL para el navegador, se programa imperativamente. R3F es un envoltorio que te deja escribir esa misma escena en JSX como si fueran componentes React, y el estado de tu app queda sincronizado con la escena automáticamente. Por debajo, R3F sigue usando Three.js.

CashDrop · juego web tipo concurso

Repo: github.com/AleixAj/cashdrop Demo: cashdrop.aleixaj.com

Recreación del concurso Cash Drop: 1.000.000 € en 20 fajos, 5 categorías × 3 niveles × 125 preguntas, drag & drop táctil propio, animaciones de confeti/lluvia con <canvas>, todo sin frameworks ni build step. El repo es directamente desplegable.

HTML5 semántico CSS3 moderno JavaScript vanilla (ES2020) Bootstrap 5.3 (CDN) Bootstrap Icons (CDN) Google Fonts Sin build step

Para qué sirve cada una

  • HTML5 semántico · estructura del juego (pantallas, modales, mesa central, opciones).
  • CSS3 moderno · estilos. Aprovecha grid, clamp() (tamaños fluidos), color-mix(), backdrop-filter, gradientes y máscaras para no depender de un framework de UI completo.
  • JavaScript vanilla · motor del juego: control de fajos, validación de apuestas, lógica de rondas, sin npm install ni transpilación.
  • Bootstrap 5.3 desde CDN · utilidades de layout (grid, spacing). En la versión 5 ya no depende de jQuery, va con JS propio.
  • Bootstrap Icons · pack de iconos por clase CSS (bi-globe-americas).
  • Google Fonts · tipografías web (Poppins para texto, Russo One para títulos).
  • Sin build step · decisión consciente: el repositorio que ves es el código que se sirve en producción. Cero pipeline, cero dependencias en runtime.

FamilyTrivia · trivia familiar por equipos

Repo: github.com/AleixAj/familytrivia Demo: familytrivia.aleixaj.com

Juego tipo tablero para reuniones familiares. Tiene ruletas para formar equipos equilibrados, 6 categorías × 6 niveles de puntuación, comodines, preguntas con audio (bandas sonoras), y un ranking final con gráfico de evolución de puntos. Sin frameworks, sin build.

HTML5 CSS3 JavaScript vanilla Bootstrap (CDN) Bootstrap Icons Chart.js Google Fonts Pointer Events API

Para qué sirve cada una

  • HTML5 · dos páginas: index.html con el tablero y ruletas.html para formar equipos.
  • CSS3 · estilos responsive para escritorio, tablet y móvil, animaciones (entrada escalonada de opciones, pulso del marcador, confeti).
  • JavaScript vanilla · lógica del tablero, ruletas, comodines, puntuaciones, estadísticas finales.
  • Bootstrap CDN · utilidades de layout (grid, navbar, modales).
  • Bootstrap Icons · iconografía consistente.
  • Chart.js · librería de gráficos. La uso para pintar la evolución de puntos por equipo al final de la partida.
  • Google Fonts · tipografías personalizadas.
  • Pointer Events API · para arrastrar la barra del reproductor de audio funcionando igual con ratón y con dedo. La uso con touch-action: none para que el navegador no haga scroll mientras arrastras.
Si te preguntan por Chart.js · es la librería de gráficos más usada en JS para gráficos sencillos (líneas, barras, dona, radar). Le pasas un objeto de datos y opciones y dibuja el gráfico en un <canvas>. Alternativas que conozco: Recharts (mejor con React), D3 (potente pero verbose), ApexCharts y ECharts.

Portfolio 3D · aleixaj.com (este mismo portfolio)

Repo: github.com/AleixAj/mi-portfolio-3d Demo: aleixaj.com

Mi portfolio público. Escena 3D interactiva en el Hero con un modelo GLB, contenido trilingüe ES/EN/CAT, SEO multiidioma, accesibilidad cuidada (focus trap, aria-current, navegación por teclado), animaciones reveal, galería de arte con modal, formulario de contacto y deploy en Cloudflare. Todo está optimizado para que cargue rápido a pesar de tener WebGL.

React 19 Vite 8 Tailwind CSS 3 Three.js React Three Fiber @react-three/drei React Icons EmailJS Sharp (build) gltf-transform Cloudflare Workers + Assets i18n custom (ES/EN/CAT)

Para qué sirve cada una

  • React 19 · UI completa: navbar, hero, secciones de trayectoria, proyectos, tecnologías, arte y contacto.
  • Vite 8 · dev server con HMR y bundle de producción con code splitting manual (React, Three.js y EmailJS en chunks separados para mejor caché).
  • Tailwind CSS 3 · estilos y responsive con breakpoints custom (2xl desde 2200px, 3xl desde 2560px y ls para landscape móvil).
  • Three.js · librería 3D que renderiza la escena del Hero (el "gaming room" GLB).
  • React Three Fiber · escribir esa escena 3D como JSX de React, sincronizada con el estado de la app.
  • @react-three/drei · helpers para R3F: OrbitControls (rotar el modelo), useGLTF (carga del modelo), PerformanceMonitor (baja el DPR automáticamente si caen los FPS), Stars (fondo estrellado).
  • React Icons · biblioteca con los iconos de marca (React, Tailwind, GitHub, LinkedIn, Godot, Aseprite...) consumidos por componente.
  • EmailJS · envío del formulario de contacto sin necesidad de backend. Las credenciales viven en variables VITE_*.
  • Sharp · usada solo en build (scripts/optimize-images.mjs) para convertir PNG → WebP y generar miniaturas de la galería de arte.
  • gltf-transform · CLI usado offline para comprimir el modelo 3D con meshopt y convertir texturas a WebP (reduce el GLB en un ~85%).
  • Cloudflare Workers + Assets · hosting global con fallback SPA configurado.
  • i18n custom · todo el contenido vive en src/consts/ (i18n, nav, projects, experience, skills) como una sola fuente de verdad trilingüe. Sin librería externa.
Decisiones técnicas que merece la pena mencionar
  • Lazy loading con React.lazy para la escena 3D y el fondo de estrellas: el bundle inicial es pequeño y Three.js solo carga cuando llega su turno.
  • DPR adaptativo: PerformanceMonitor baja la resolución del canvas si el FPS cae por debajo de ~45, y la sube cuando vuelve a ser estable.
  • Scroll lock controlado: html y body con overflow: hidden y el contenido vive en un contenedor fixed con su propio scroll. Soluciona el "salto" típico en móvil cuando se oculta la barra del navegador (el cambio de 100dvh).
  • SEO multiidioma: hreflang ES/EN/CA en index.html y sincronización en runtime de title, description, og:* y twitter:* al cambiar de idioma.
  • Accesibilidad: focus-visible global, focus trap en el menú móvil, aria-current="page" en la sección activa, banderas SVG inline para que se vean igual en cualquier sistema.
Argumento de venta · este es el proyecto que probablemente el reclutador habrá mirado antes de llamarte. Si te preguntan "¿qué destacarías?" responde: "Que pese a tener una escena 3D con WebGL, el portfolio mantiene buen rendimiento gracias a code splitting, lazy loading y DPR adaptativo. Y que todo el contenido es trilingüe sin librería externa, con la metadata SEO sincronizándose al cambiar de idioma."

Resumen rápido para la entrevista

Si tienes 10 segundos para describir cada proyecto:

Proyecto En una frase Tecnología principal a destacar
Obsidian E-commerce full-stack con React + Laravel API REST, auth con sesiones, TanStack Query
Lord of the Clicks Clicker incremental con motor desacoplado de UI Zustand, TypeScript estricto, Vitest, CI
Solar Explorer Sistema Solar 3D interactivo en navegador Three.js + React Three Fiber, WebGL
CashDrop Concurso TV con drag & drop táctil propio JS vanilla, sin build, <canvas> animado
FamilyTrivia Trivia familiar por equipos con ruletas y audio JS vanilla, Chart.js, Pointer Events API
Portfolio 3D Mi portfolio web con escena 3D y trilingüe React + R3F, code splitting, i18n ES/EN/CAT, SEO sincronizado
Pista de presentación · si te preguntan abierto "¿qué proyectos tienes?", empieza por Obsidian (es el más completo: full-stack, auth real, deploy a producción, API tipada). Si la conversación va por temas frontend modernos, salta a Lord of the Clicks (TS estricto, Zustand, tests, CI). Si te preguntan por algo "diferente" o visual, mete Solar Explorer o el Portfolio 3D (ambos con Three.js + R3F). Los dos juegos web (CashDrop, FamilyTrivia) son tu "muestra de fundamentos sin frameworks": útiles si te preguntan por JS vanilla, Bootstrap o trabajo directo con el DOM y <canvas>. Recuerda que el Portfolio 3D es el que muy probablemente ya habrán visto, así que tener claro su stack te da puntos de inmediato.