Neste guia, vamos focar nos conceitos fundamentais de uma Single Page Application (SPA), construindo uma interface de usuário do zero com JavaScript puro (“vanilla”). Esta abordagem, embora mais verbosa que usar um framework como React ou Vue, é a melhor forma de aprender os mecanismos que operam por baixo do capô.
Objetivo: Mostrar como o backend robusto que foi construído pode ser consumido por um cliente web moderno. Ao final deste módulo, o aluno terá uma SPA funcional que realiza login, exibe dados e interage com a API, tudo sem recarregar a página.
Conceito-Chave: A Mudança de Paradigma Até agora, poderíamos ter usado o Thymeleaf, uma abordagem de Renderização no Lado do Servidor (Server-Side Rendering - SSR).
/produtos).Agora, vamos adotar a abordagem de Renderização no Lado do Cliente (Client-Side Rendering - CSR), a base de uma SPA.
| Característica | SSR (Thymeleaf) | CSR (Single Page Application) |
|---|---|---|
| Carga Inicial | Mais rápida para a primeira visualização. | Mais lenta (baixa todo o app), mas instantânea depois. |
| Navegação | Lenta, recarrega a página a cada clique. | Rápida e fluida, sem recarregamento da página. |
| Carga no Servidor | Maior (precisa renderizar HTML a cada vez). | Menor (serve apenas JSON e arquivos estáticos). |
| Arquitetura | Acoplada (Frontend e Backend no mesmo processo). | Desacoplada (Backend é uma API, Frontend é um cliente). |
Nossa SPA viverá inteiramente no diretório src/main/resources/static/. O próprio Spring Boot servirá esses arquivos.
Ação: Crie os arquivos HTML e uma folha de estilos básica.
static/login.html<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - Gestão de Estoque</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/7.2.0/mdb.min.css" rel="stylesheet"/>
<link href="/css/style.css" rel="stylesheet" />
</head>
<body>
<div class="container">
<div class="row justify-content-center align-items-center vh-100">
<div class="col-md-6 col-lg-4">
<div class="card shadow-5-strong">
<div class="card-header text-center bg-dark text-white p-4">
<h2 class="h4 mb-1">Gestão de Estoque</h2>
</div>
<div class="card-body p-4">
<div id="error-message" class="alert alert-danger d-none" role="alert"></div>
<form id="login-form">
<div class="form-outline mb-4" data-mdb-input-init>
<input type="text" id="username" class="form-control" required />
<label class="form-label" for="username">Usuário (admin)</label>
</div>
<div class="form-outline mb-4" data-mdb-input-init>
<input type="password" id="password" class="form-control" required />
<label class="form-label" for="password">Senha (admin123)</label>
</div>
<button type="submit" class="btn btn-primary btn-block mb-4" data-mdb-ripple-init>Entrar</button>
</form>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/7.2.0/mdb.umd.min.js"></script>
<script src="/js/auth.js"></script>
</body>
</html>
static/index.html (A “casca” da nossa SPA)<!DOCTYPE html>
<html lang="pt-br">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gestão de Estoque</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/7.2.0/mdb.min.css" rel="stylesheet"/>
<link href="/css/style.css" rel="stylesheet"/>
</head>
<body>
<header>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
<div class="container-fluid">
<a class="navbar-brand" href="#/produtos"><i class="fas fa-boxes-stacked me-2"></i>Gestão de Estoque</a>
<ul class="navbar-nav d-flex flex-row">
<li class="nav-item me-3">
<a class="nav-link" href="#/categorias">Categorias</a>
</li>
<li class="nav-item me-3">
<a class="nav-link" href="#/fornecedores">Fornecedores</a>
</li>
<li class="nav-item">
<button id="logout-button" class="btn btn-outline-danger btn-rounded">Sair</button>
</li>
</ul>
</div>
</nav>
</header>
<main id="app-content" class="container my-5">
</main>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/mdb-ui-kit/7.2.0/mdb.umd.min.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/api.js"></script>
<script src="/js/router.js"></script>
</body>
</html>
static/css/style.css/* Estilos globais para a aplicação */
.table-hover tbody tr:hover {
cursor: pointer;
background-color: rgba(0, 0, 0, 0.075);
}
auth.js)Conceito-Chave: O localStorage é um mecanismo do navegador que nos permite salvar dados (como o token JWT) que persistem mesmo se o navegador for fechado.
Ação: Crie o arquivo js/auth.js para centralizar toda a lógica de autenticação do lado do cliente.
static/js/auth.js// Este objeto encapsula toda a lógica de autenticação
const auth = {
// Salva o token no localStorage
saveToken: (token) => {
localStorage.setItem('jwt_token', token);
},
// Pega o token do localStorage
getToken: () => {
return localStorage.getItem('jwt_token');
},
// Remove o token para fazer logout
removeToken: () => {
localStorage.removeItem('jwt_token');
},
// Verifica se existe um token
isLoggedIn: () => {
const token = auth.getToken();
return !!token; // Retorna true se o token existir, false caso contrário
},
// Lida com o processo de login
handleLogin: async (username, password) => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ login: username, password: password })
});
if (!response.ok) {
throw new Error('Usuário ou senha inválidos.');
}
const data = await response.json();
auth.saveToken(data.token); // Salva o token recebido
window.location.href = '/index.html'; // Redireciona para a página principal
} catch (error) {
const errorDiv = document.getElementById('error-message');
errorDiv.textContent = error.message;
errorDiv.classList.remove('d-none');
}
},
// Lida com o processo de logout
handleLogout: () => {
auth.removeToken();
window.location.href = '/login.html'; // Redireciona para a página de login
}
};
// Adiciona o listener de evento APENAS se estivermos na página de login
if (window.location.pathname.endsWith('login.html')) {
document.getElementById('login-form').addEventListener('submit', (event) => {
event.preventDefault(); // Impede o envio padrão do formulário
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
auth.handleLogin(username, password);
});
}
// Adiciona o listener de evento APENAS se estivermos na página principal
if (window.location.pathname.endsWith('index.html')) {
document.getElementById('logout-button').addEventListener('click', auth.handleLogout);
}
api.js)Conceito-Chave: Centralizar as chamadas de API em um único módulo (api.js) é uma boa prática. Isso nos permite adicionar lógica comum a todas as requisições, como incluir o token de autorização.
Ação: Crie o arquivo js/api.js que fará as chamadas fetch para nosso backend.
static/js/api.js// Módulo para centralizar todas as chamadas à API
const api = {
// Função auxiliar genérica para requisições
fetch: async (endpoint, method = 'GET', body = null) => {
const token = auth.getToken();
const headers = {
'Content-Type': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const config = {
method: method,
headers: headers
};
if (body) {
config.body = JSON.stringify(body);
}
const response = await fetch(`/api${endpoint}`, config);
// Se o token for inválido/expirado, o servidor retornará 403.
// Devemos deslogar o usuário.
if (response.status === 403 || response.status === 401) {
auth.handleLogout();
return;
}
if (!response.ok) {
throw new Error(`Erro na API: ${response.statusText}`);
}
// Retorna um JSON vazio para respostas 204 No Content
if (response.status === 204) {
return {};
}
return response.json();
},
// Funções específicas para cada recurso
getProdutos: () => api.fetch('/produtos'),
deleteProduto: (id) => api.fetch(`/produtos/${id}`, 'DELETE'),
getCategorias: () => api.fetch('/categorias'),
// ... adicione outras funções conforme necessário (getFornecedores, createProduto, etc.)
};
router.js)Conceito-Chave: Um roteador de cliente observa a URL e, em vez de pedir uma nova página ao servidor, ele decide qual conteúdo JavaScript deve renderizar na tela. Usaremos uma técnica simples baseada no “hash” (#) da URL.
Ação: Crie o arquivo js/router.js para controlar qual “página” é exibida.
static/js/router.jsconst appContent = document.getElementById('app-content');
// Templates HTML para cada "página"
const renderProdutos = async () => {
try {
const produtos = await api.getProdutos();
const tableRows = produtos.map(p => `
<tr>
<td>${p.id}</td> <td>${p.nome}</td>
<td>${p.quantidade}</td> <td>R$ ${p.preco.toFixed(2)}</td> <td><span class="badge badge-primary rounded-pill d-inline">${p.nomeCategoria}</span></td>
<td>
<button class="btn btn-sm btn-danger btn-floating" data-id="${p.id}" data-action="delete">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
return `
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Produtos em Estoque</h1>
<a href="#/produtos/novo" class="btn btn-primary btn-rounded"><i class="fas fa-plus me-2"></i>Adicionar Produto</a>
</div>
<div class="card"><div class="card-body">
<table class="table align-middle mb-0 bg-white table-hover">
<thead class="bg-light">
<tr><th>ID</th><th>Nome</th><th>Qtd.</th><th>Preço</th><th>Categoria</th><th>Ações</th></tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</div></div>
`;
} catch (error) {
return `<div class="alert alert-danger">Erro ao carregar produtos.</div>`;
}
};
const renderCategorias = async () => {
// Lógica similar para buscar e renderizar categorias
return '<h1>Página de Categorias (a implementar)</h1>';
};
// Mapa de rotas
const routes = {
'/produtos': renderProdutos,
'/categorias': renderCategorias,
// Adicione outras rotas aqui
};
// Função principal do roteador
const router = async () => {
// Proteção de rota: se não estiver logado, manda para o login.
if (!auth.isLoggedIn()) {
window.location.pathname = '/login.html';
return;
}
// Pega o caminho do hash ou vai para a página padrão
const path = window.location.hash.substring(1) || '/produtos';
const renderFunction = routes[path] || (() => '<h2>Página não encontrada</h2>');
appContent.innerHTML = await renderFunction();
};
// O roteador é acionado quando a página carrega ou o hash da URL muda
window.addEventListener('load', router);
window.addEventListener('hashchange', router);
// Delegação de eventos para botões de ação (ex: deletar)
appContent.addEventListener('click', async (event) => {
const target = event.target.closest('[data-action="delete"]');
if (target) {
const id = target.dataset.id;
if (confirm(`Tem certeza que deseja deletar o produto ID ${id}?`)) {
try {
await api.deleteProduto(id);
alert('Produto deletado com sucesso!');
router(); // Recarrega a view atual
} catch (error) {
alert('Erro ao deletar produto. Você tem permissão de ADMIN?');
}
}
}
});
Parabéns! Você construiu uma Single Page Application funcional do zero. Embora simples, ela demonstra todos os conceitos fundamentais:
Este conhecimento é a base para o uso de frameworks modernos como React, Vue e Angular, que resolvem muitos desses problemas (roteamento, renderização, gerenciamento de estado) de forma mais eficiente e escalável.
No próximo e último módulo, aprenderemos a empacotar nossa aplicação backend com Docker, preparando-a para o deploy em qualquer ambiente.