18-Tutorial React - Consumir API Laravel con Autenticaci贸n JWT (Versi贸n Sencilla)


馃殌 Paso 1: Configuraci贸n Inicial (5 minutos)

bash
# 1.1 Crear proyecto React
npx create-react-app gestor-tareas-jwt
cd gestor-tareas-jwt

# 1.2 Eliminar archivos innecesarios
rm src/App.test.js src/logo.svg src/reportWebVitals.js src/setupTests.js

# 1.3 Iniciar el proyecto
npm start

馃搧 Paso 2: Estructura de Carpetas (2 minutos)

text
src/
├── components/
│   ├── LoginForm.js
│   ├── RegisterForm.js
│   ├── TareaList.js
│   ├── TareaForm.js
│   └── TareaItem.js
├── services/
│   └── authService.js
├── App.js
└── index.js

馃搫 Paso 3: index.js (1 minuto)

jsx
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

馃摝 Paso 4: App.js inicial (2 minutos)

jsx
// src/App.js
import React, { useState } from 'react';
import LoginForm from './components/LoginForm';
import RegisterForm from './components/RegisterForm';
import TareaList from './components/TareaList';
import authService from './services/authService';

function App() {
  const [usuario, setUsuario] = useState(authService.getUsuario());
  const [mostrarRegistro, setMostrarRegistro] = useState(false);

  const handleLogin = (user) => {
    setUsuario(user);
  };

  const handleLogout = () => {
    authService.logout();
    setUsuario(null);
  };

  return (
    <div style={{ padding: '20px' }}>
      <h1>馃摑 Gestor de Tareas con JWT</h1>
      
      {usuario ? (
        <>
          <div style={{ marginBottom: '20px' }}>
            <span>Bienvenido, {usuario.name}</span>
            <button 
              onClick={handleLogout}
              style={{ marginLeft: '10px', padding: '5px 10px' }}
            >
              Cerrar Sesi贸n
            </button>
          </div>
          <TareaList />
        </>
      ) : (
        <div style={{ maxWidth: '400px', margin: '0 auto' }}>
          {mostrarRegistro ? (
            <>
              <RegisterForm 
                onSuccess={() => setMostrarRegistro(false)}
                onCancel={() => setMostrarRegistro(false)}
              />
              <p style={{ textAlign: 'center', marginTop: '10px' }}>
                ¿Ya tienes cuenta?{' '}
                <button 
                  onClick={() => setMostrarRegistro(false)}
                  style={{ background: 'none', border: 'none', color: 'blue', cursor: 'pointer' }}
                >
                  Iniciar Sesi贸n
                </button>
              </p>
            </>
          ) : (
            <>
              <LoginForm onSuccess={handleLogin} />
              <p style={{ textAlign: 'center', marginTop: '10px' }}>
                ¿No tienes cuenta?{' '}
                <button 
                  onClick={() => setMostrarRegistro(true)}
                  style={{ background: 'none', border: 'none', color: 'blue', cursor: 'pointer' }}
                >
                  Reg铆strate
                </button>
              </p>
            </>
          )}
        </div>
      )}
    </div>
  );
}

export default App;

馃攽 Paso 5: Servicio de Autenticaci贸n (10 minutos)

jsx
// src/services/authService.js

const API_URL = 'http://localhost:8000/api';

class AuthService {
  constructor() {
    this.tokenKey = 'jwt_token';
    this.userKey = 'user_data';
  }

  // Guardar token y usuario
  setAuthData(token, user) {
    localStorage.setItem(this.tokenKey, token);
    localStorage.setItem(this.userKey, JSON.stringify(user));
  }

  // Obtener token
  getToken() {
    return localStorage.getItem(this.tokenKey);
  }

  // Obtener usuario
  getUsuario() {
    const user = localStorage.getItem(this.userKey);
    return user ? JSON.parse(user) : null;
  }

  // Verificar si est谩 autenticado
  isAuthenticated() {
    return !!this.getToken();
  }

  // Limpiar datos de autenticaci贸n
  logout() {
    localStorage.removeItem(this.tokenKey);
    localStorage.removeItem(this.userKey);
  }

  // Configuraci贸n para peticiones con token
  getAuthHeaders() {
    const token = this.getToken();
    return {
      'Content-Type': 'application/json',
      'Authorization': token ? `Bearer ${token}` : '',
      'Accept': 'application/json'
    };
  }

  // 1. Iniciar sesi贸n
  async login(email, password) {
    try {
      const response = await fetch(`${API_URL}/login`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json'
        },
        body: JSON.stringify({ email, password })
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Error al iniciar sesi贸n');
      }

      const data = await response.json();
      
      // Guardar token y datos del usuario
      this.setAuthData(data.token, data.user);
      
      return { success: true, user: data.user };
      
    } catch (error) {
      console.error('Error en login:', error);
      return { success: false, message: error.message };
    }
  }

  // 2. Registrarse
  async register(name, email, password, password_confirmation) {
    try {
      const response = await fetch(`${API_URL}/register`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json'
        },
        body: JSON.stringify({
          name,
          email,
          password,
          password_confirmation
        })
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Error al registrarse');
      }

      const data = await response.json();
      
      // Guardar token y datos del usuario
      this.setAuthData(data.token, data.user);
      
      return { success: true, user: data.user };
      
    } catch (error) {
      console.error('Error en registro:', error);
      return { success: false, message: error.message };
    }
  }

  // 3. Cerrar sesi贸n
  async logoutApi() {
    try {
      const token = this.getToken();
      if (token) {
        await fetch(`${API_URL}/logout`, {
          method: 'POST',
          headers: {
            'Authorization': `Bearer ${token}`,
            'Accept': 'application/json'
          }
        });
      }
    } catch (error) {
      console.error('Error en logout:', error);
    } finally {
      this.logout();
    }
  }
}

// Crear una instancia 煤nica
const authService = new AuthService();
export default authService;

馃搵 Paso 6: Servicio de Tareas (10 minutos)

jsx
// src/services/tareaService.js
import authService from './authService';

const API_URL = 'http://localhost:8000/api';

class TareaService {
  // Verificar autenticaci贸n antes de cada petici贸n
  checkAuth() {
    if (!authService.isAuthenticated()) {
      throw new Error('No est谩s autenticado. Por favor, inicia sesi贸n.');
    }
  }

  // Obtener headers con token
  getHeaders() {
    this.checkAuth();
    return authService.getAuthHeaders();
  }

  // 1. Obtener todas las tareas del usuario
  async obtenerTodas() {
    try {
      const response = await fetch(`${API_URL}/tareas`, {
        method: 'GET',
        headers: this.getHeaders()
      });

      if (response.status === 401) {
        authService.logout();
        throw new Error('Sesi贸n expirada. Por favor, inicia sesi贸n nuevamente.');
      }

      if (!response.ok) {
        throw new Error('Error al obtener tareas');
      }

      const data = await response.json();
      return data;
      
    } catch (error) {
      console.error('Error:', error);
      throw error;
    }
  }

  // 2. Crear nueva tarea
  async crearTarea(tareaData) {
    try {
      const response = await fetch(`${API_URL}/tareas`, {
        method: 'POST',
        headers: this.getHeaders(),
        body: JSON.stringify(tareaData)
      });

      if (response.status === 401) {
        authService.logout();
        throw new Error('Sesi贸n expirada');
      }

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.message || 'Error al crear tarea');
      }

      return await response.json();
      
    } catch (error) {
      console.error('Error:', error);
      throw error;
    }
  }

  // 3. Actualizar tarea (solo para completar/descompletar)
  async actualizarTarea(id, tareaData) {
    try {
      const response = await fetch(`${API_URL}/tareas/${id}`, {
        method: 'PUT',
        headers: this.getHeaders(),
        body: JSON.stringify(tareaData)
      });

      if (response.status === 401) {
        authService.logout();
        throw new Error('Sesi贸n expirada');
      }

      if (!response.ok) {
        throw new Error('Error al actualizar tarea');
      }

      return await response.json();
      
    } catch (error) {
      console.error('Error:', error);
      throw error;
    }
  }

  // 4. Eliminar tarea
  async eliminarTarea(id) {
    try {
      const response = await fetch(`${API_URL}/tareas/${id}`, {
        method: 'DELETE',
        headers: this.getHeaders()
      });

      if (response.status === 401) {
        authService.logout();
        throw new Error('Sesi贸n expirada');
      }

      if (!response.ok) {
        throw new Error('Error al eliminar tarea');
      }

      return await response.json();
      
    } catch (error) {
      console.error('Error:', error);
      throw error;
    }
  }
}

// Crear una instancia 煤nica
const tareaService = new TareaService();
export default tareaService;

✏️ Paso 7: LoginForm.js (10 minutos)

jsx
// src/components/LoginForm.js
import React, { useState } from 'react';
import authService from '../services/authService';

function LoginForm({ onSuccess }) {
  const [formData, setFormData] = useState({
    email: '',
    password: ''
  });
  const [error, setError] = useState('');
  const [cargando, setCargando] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    setCargando(true);

    try {
      const resultado = await authService.login(formData.email, formData.password);
      
      if (resultado.success) {
        onSuccess(resultado.user);
      } else {
        setError(resultado.message || 'Error al iniciar sesi贸n');
      }
    } catch (error) {
      setError('Error de conexi贸n. Verifica tu conexi贸n a internet.');
    } finally {
      setCargando(false);
    }
  };

  return (
    <div style={{ border: '1px solid #ccc', padding: '20px', borderRadius: '5px' }}>
      <h2 style={{ marginTop: 0 }}>Iniciar Sesi贸n</h2>
      
      {error && (
        <div style={{ background: '#ffebee', color: '#c62828', padding: '10px', marginBottom: '15px', borderRadius: '4px' }}>
          {error}
        </div>
      )}
      
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Email:</label>
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            placeholder="tu@email.com"
            style={{ width: '100%', padding: '8px' }}
            required
            disabled={cargando}
          />
        </div>
        
        <div style={{ marginBottom: '20px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Contrase帽a:</label>
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            placeholder="Tu contrase帽a"
            style={{ width: '100%', padding: '8px' }}
            required
            disabled={cargando}
          />
        </div>
        
        <button 
          type="submit" 
          disabled={cargando}
          style={{ 
            width: '100%', 
            padding: '10px',
            backgroundColor: '#4CAF50',
            color: 'white',
            border: 'none',
            borderRadius: '4px',
            cursor: cargando ? 'not-allowed' : 'pointer'
          }}
        >
          {cargando ? 'Iniciando sesi贸n...' : 'Iniciar Sesi贸n'}
        </button>
      </form>
    </div>
  );
}

export default LoginForm;

馃摑 Paso 8: RegisterForm.js (10 minutos)

jsx
// src/components/RegisterForm.js
import React, { useState } from 'react';
import authService from '../services/authService';

function RegisterForm({ onSuccess, onCancel }) {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: '',
    password_confirmation: ''
  });
  const [error, setError] = useState('');
  const [cargando, setCargando] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData({
      ...formData,
      [name]: value
    });
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    
    // Validaci贸n b谩sica
    if (formData.password !== formData.password_confirmation) {
      setError('Las contrase帽as no coinciden');
      return;
    }
    
    if (formData.password.length < 6) {
      setError('La contrase帽a debe tener al menos 6 caracteres');
      return;
    }
    
    setError('');
    setCargando(true);

    try {
      const resultado = await authService.register(
        formData.name,
        formData.email,
        formData.password,
        formData.password_confirmation
      );
      
      if (resultado.success) {
        onSuccess(resultado.user);
      } else {
        setError(resultado.message || 'Error al registrarse');
      }
    } catch (error) {
      setError('Error de conexi贸n. Verifica tu conexi贸n a internet.');
    } finally {
      setCargando(false);
    }
  };

  return (
    <div style={{ border: '1px solid #ccc', padding: '20px', borderRadius: '5px' }}>
      <h2 style={{ marginTop: 0 }}>Registrarse</h2>
      
      {error && (
        <div style={{ background: '#ffebee', color: '#c62828', padding: '10px', marginBottom: '15px', borderRadius: '4px' }}>
          {error}
        </div>
      )}
      
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Nombre:</label>
          <input
            type="text"
            name="name"
            value={formData.name}
            onChange={handleChange}
            placeholder="Tu nombre"
            style={{ width: '100%', padding: '8px' }}
            required
            disabled={cargando}
          />
        </div>
        
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Email:</label>
          <input
            type="email"
            name="email"
            value={formData.email}
            onChange={handleChange}
            placeholder="tu@email.com"
            style={{ width: '100%', padding: '8px' }}
            required
            disabled={cargando}
          />
        </div>
        
        <div style={{ marginBottom: '15px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Contrase帽a:</label>
          <input
            type="password"
            name="password"
            value={formData.password}
            onChange={handleChange}
            placeholder="M铆nimo 6 caracteres"
            style={{ width: '100%', padding: '8px' }}
            required
            disabled={cargando}
          />
        </div>
        
        <div style={{ marginBottom: '20px' }}>
          <label style={{ display: 'block', marginBottom: '5px' }}>Confirmar Contrase帽a:</label>
          <input
            type="password"
            name="password_confirmation"
            value={formData.password_confirmation}
            onChange={handleChange}
            placeholder="Repite tu contrase帽a"
            style={{ width: '100%', padding: '8px' }}
            required
            disabled={cargando}
          />
        </div>
        
        <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
          <button 
            type="submit" 
            disabled={cargando}
            style={{ 
              padding: '10px',
              backgroundColor: '#2196F3',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: cargando ? 'not-allowed' : 'pointer'
            }}
          >
            {cargando ? 'Registrando...' : 'Registrarse'}
          </button>
          
          <button 
            type="button"
            onClick={onCancel}
            disabled={cargando}
            style={{ 
              padding: '10px',
              backgroundColor: '#9E9E9E',
              color: 'white',
              border: 'none',
              borderRadius: '4px',
              cursor: cargando ? 'not-allowed' : 'pointer'
            }}
          >
            Cancelar
          </button>
        </div>
      </form>
    </div>
  );
}

export default RegisterForm;

馃搵 Paso 9: TareaList.js con CSS Grid (15 minutos)

jsx
// src/components/TareaList.js
import React, { useState, useEffect } from 'react';
import TareaForm from './TareaForm';
import TareaItem from './TareaItem';
import tareaService from '../services/tareaService';

function TareaList() {
  const [tareas, setTareas] = useState([]);
  const [cargando, setCargando] = useState(true);
  const [error, setError] = useState('');

  // CSS Grid para estructura b谩sica
  const estiloContenedor = {
    display: 'grid',
    gridTemplateColumns: '300px 1fr', // 2 columnas
    gap: '20px',
    marginTop: '20px'
  };

  const estiloColumna = {
    border: '1px solid #333',
    padding: '15px'
  };

  // Cargar tareas al iniciar
  useEffect(() => {
    cargarTareas();
  }, []);

  // Funci贸n para cargar tareas
  const cargarTareas = async () => {
    try {
      setCargando(true);
      setError('');
      const datos = await tareaService.obtenerTodas();
      setTareas(datos);
    } catch (error) {
      if (error.message.includes('Sesi贸n expirada')) {
        setError('Tu sesi贸n ha expirado. Por favor, inicia sesi贸n nuevamente.');
      } else {
        setError('Error al cargar tareas: ' + error.message);
      }
    } finally {
      setCargando(false);
    }
  };

  // Funci贸n para crear tarea
  const manejarCrearTarea = async (nuevaTarea) => {
    try {
      await tareaService.crearTarea(nuevaTarea);
      cargarTareas();
    } catch (error) {
      alert('Error al crear la tarea: ' + error.message);
    }
  };

  // Funci贸n para eliminar tarea
  const manejarEliminarTarea = async (id) => {
    if (window.confirm('¿Eliminar esta tarea?')) {
      try {
        await tareaService.eliminarTarea(id);
        cargarTareas();
      } catch (error) {
        alert('Error al eliminar la tarea: ' + error.message);
      }
    }
  };

  // Funci贸n para completar tarea
  const manejarCompletarTarea = async (id, completada) => {
    try {
      await tareaService.actualizarTarea(id, { completada });
      cargarTareas();
    } catch (error) {
      alert('Error al actualizar la tarea: ' + error.message);
    }
  };

  return (
    <div>
      <div style={estiloContenedor}>
        {/* COLUMNA IZQUIERDA: Formulario */}
        <div style={estiloColumna}>
          <h2 style={{ marginTop: 0 }}>➕ Nueva Tarea</h2>
          <TareaForm onSubmit={manejarCrearTarea} />
        </div>

        {/* COLUMNA DERECHA: Lista de tareas */}
        <div style={estiloColumna}>
          <h2 style={{ marginTop: 0 }}>馃搵 Mis Tareas</h2>
          
          {error && (
            <div style={{ background: '#ffebee', color: '#c62828', padding: '10px', marginBottom: '15px', borderRadius: '4px' }}>
              {error}
            </div>
          )}
          
          {cargando ? (
            <p>Cargando tus tareas...</p>
          ) : tareas.length === 0 ? (
            <p>No tienes tareas. ¡Crea tu primera tarea!</p>
          ) : (
            <>
              <p>Total: {tareas.length} tareas</p>
              {tareas.map((tarea) => (
                <TareaItem
                  key={tarea.id}
                  tarea={tarea}
                  onEliminar={manejarEliminarTarea}
                  onCompletar={manejarCompletarTarea}
                />
              ))}
            </>
          )}
          
          <div style={{ marginTop: '20px' }}>
            <button 
              onClick={cargarTareas} 
              style={{ 
                padding: '8px 15px',
                backgroundColor: '#4CAF50',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
              }}
            >
              馃攧 Recargar Tareas
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

export default TareaList;

✏️ Paso 10: TareaForm.js (10 minutos)

jsx
// src/components/TareaForm.js
import React, { useState } from 'react';

function TareaForm({ onSubmit }) {
  const [formData, setFormData] = useState({
    nombre: '',
    descripcion: '',
    completada: false
  });

  const [error, setError] = useState('');

  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData({
      ...formData,
      [name]: type === 'checkbox' ? checked : value
    });
  };

  const handleSubmit = (e) => {
    e.preventDefault();
    
    if (!formData.nombre.trim()) {
      setError('El nombre de la tarea es obligatorio');
      return;
    }
    
    onSubmit(formData);
    setFormData({ nombre: '', descripcion: '', completada: false });
    setError('');
  };

  return (
    <form onSubmit={handleSubmit}>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      
      <div style={{ marginBottom: '15px' }}>
        <label style={{ display: 'block', marginBottom: '5px' }}>
          Nombre de la tarea:
        </label>
        <input
          type="text"
          name="nombre"
          value={formData.nombre}
          onChange={handleChange}
          placeholder="Ej: Comprar leche"
          style={{ width: '100%', padding: '8px' }}
        />
      </div>
      
      <div style={{ marginBottom: '15px' }}>
        <label style={{ display: 'block', marginBottom: '5px' }}>
          Descripci贸n (opcional):
        </label>
        <textarea
          name="descripcion"
          value={formData.descripcion}
          onChange={handleChange}
          placeholder="Ej: Ir al supermercado"
          rows="3"
          style={{ width: '100%', padding: '8px' }}
        />
      </div>
      
      <div style={{ marginBottom: '20px' }}>
        <label>
          <input
            type="checkbox"
            name="completada"
            checked={formData.completada}
            onChange={handleChange}
          />
          <span style={{ marginLeft: '5px' }}>Marcar como completada</span>
        </label>
      </div>
      
      <button 
        type="submit" 
        style={{ 
          width: '100%', 
          padding: '10px',
          backgroundColor: '#4CAF50',
          color: 'white',
          border: 'none'
        }}
      >
        ➕ Crear Tarea
      </button>
    </form>
  );
}

export default TareaForm;

馃摝 Paso 11: TareaItem.js (10 minutos)

jsx
// src/components/TareaItem.js
import React from 'react';

function TareaItem({ tarea, onEliminar, onCompletar }) {
  const estiloTarea = {
    border: '1px solid #ccc',
    padding: '15px',
    marginBottom: '10px'
  };

  const estiloBotones = {
    display: 'grid',
    gridTemplateColumns: '1fr 1fr', // CSS Grid para 2 botones
    gap: '10px',
    marginTop: '10px'
  };

  return (
    <div style={estiloTarea}>
      <h3 style={{ marginTop: 0 }}>
        {tarea.completada ? '✅ ' : '⏳ '}
        {tarea.nombre}
      </h3>
      
      <p>{tarea.descripcion}</p>
      
      <div style={{ color: '#666', fontSize: '14px', marginBottom: '10px' }}>
        ID: {tarea.id} | 
        Estado: {tarea.completada ? 'Completada' : 'Pendiente'} | 
        Creada: {new Date(tarea.created_at).toLocaleDateString()}
      </div>
      
      <div style={estiloBotones}>
        <button 
          onClick={() => onCompletar(tarea.id, !tarea.completada)}
          style={{
            padding: '8px',
            backgroundColor: tarea.completada ? '#ff9800' : '#4CAF50',
            color: 'white',
            border: 'none'
          }}
        >
          {tarea.completada ? '↩️ Desmarcar' : '✅ Completar'}
        </button>
        
        <button 
          onClick={() => onEliminar(tarea.id)}
          style={{
            padding: '8px',
            backgroundColor: '#f44336',
            color: 'white',
            border: 'none'
          }}
        >
          馃棏️ Eliminar
        </button>
      </div>
    </div>
  );
}

export default TareaItem;

✅ Paso 12: Configuraci贸n de la API Laravel

Para que funcione, tu API Laravel necesita estas rutas:

php
// routes/api.php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\TareaController;

// Autenticaci贸n
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');

// Tareas (protegidas)
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('tareas', TareaController::class);
});

Y en Laravel instalar Sanctum:

bash
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

馃幆 Verificaci贸n Final

Estructura visual:

text
INICIO (No autenticado):
┌─────────────────────────┐
│                         │
│   INICIAR SESI脫N        │
│   Email: ___________    │
│   Contrase帽a: ______    │
│   [Iniciar Sesi贸n]      │
│                         │
│   ¿No tienes cuenta?    │
│   [Reg铆strate]          │
│                         │
└─────────────────────────┘

AUTENTICADO:
┌──────────────────────────────────────┬──────────────────────────────────────┐
│                                      │                                      │
│  ➕ NUEVA TAREA                       │  馃搵 MIS TAREAS                       │
│  [Nombre] _______________            │  Total: X tareas                     │
│                                      │                                      │
│  [Descripci贸n] _________             │  • ✅ Comprar leche                  │
│                     ______           │     Ir al supermercado               │
│                                      │     ID: 1 | Estado: Pendiente        │
│  [ ] Marcar como completada          │     [✅ Completar] [馃棏️ Eliminar]    │
│                                      │                                      │
│  [➕ Crear Tarea]                     │  • ✅ Estudiar React                 │
│                                      │     Hacer ejercicios                 │
│                                      │     ID: 2 | Estado: Completada       │
│                                      │     [↩️ Desmarcar] [馃棏️ Eliminar]   │
│                                      │                                      │
│                                      │  [馃攧 Recargar Tareas]                │
└──────────────────────────────────────┴──────────────────────────────────────┘

馃搳 Resumen de Tiempo (90 minutos total)

PasoTiempoDescripci贸n
15 minConfiguraci贸n inicial
22 minEstructura de carpetas
31 minindex.js
42 minApp.js inicial
510 minServicio de autenticaci贸n
610 minServicio de tareas
710 minLoginForm
810 minRegisterForm
915 minTareaList con CSS Grid
1010 minTareaForm
1110 minTareaItem
125 minConfiguraci贸n Laravel

✅ Caracter铆sticas implementadas:

1. Autenticaci贸n JWT completa:

  • ✅ Registro de usuarios

  • ✅ Inicio de sesi贸n

  • ✅ Cierre de sesi贸n

  • ✅ Persistencia de token en localStorage

  • ✅ Headers autom谩ticos con token

2. CRUD de tareas:

  • ✅ Listar tareas del usuario autenticado

  • ✅ Crear nuevas tareas

  • ✅ Marcar/desmarcar como completadas

  • ✅ Eliminar tareas

3. Interfaz m铆nima:

  • ✅ CSS Grid b谩sico para layout

  • ✅ Formularios sencillos

  • ✅ Mensajes de error claros

  • ✅ Estados de carga

4. Seguridad:

  • ✅ Token JWT en todas las peticiones

  • ✅ Verificaci贸n autom谩tica de autenticaci贸n

  • ✅ Manejo de sesiones expiradas

  • ✅ Cada usuario solo ve sus tareas

馃挕 Tips para el estudiante:

  1. Primero configurar Laravel con Sanctum para JWT

  2. Probar las rutas API con Postman o cURL

  3. Verificar localStorage para ver el token guardado

  4. Usar Network tab en DevTools para ver headers

  5. El c贸digo es m铆nimo pero funcional - perfecto para aprender

¡Perfecto! El alumno ahora tiene una aplicaci贸n React completa que consume una API Laravel con autenticaci贸n JWT, usando solo el CSS necesario para la estructura y sin funcionalidad de edici贸n.

Comentarios

Entradas m谩s populares de este blog

0-Sistema de Tareas

13-CRUD en Laravel para Web y API con Rutas Normales (No Resource)

10-Introducci贸n a Blade en Laravel con un Ejemplo Pr谩ctico