📚 Módulo 08: Frontend - Carrinho e CRUD com Vue 3
Neste módulo prático, programaremos as principais telas e interações da nossa Single Page Application. Construiremos um carrinho de compras reativo fazendo uso avançado de propriedades Computadas (computed) do Vue 3, estruturaremos o Catálogo de Eletrônicos com busca em tempo real e criaremos a área administrativa de CRUD de Produtos com validação visual integrada.
🛒 1. O Composable de Carrinho de Compras (useCart)
O Vue 3 possui um sistema de reatividade extremamente poderoso. Usaremos a função computed() para recalcular a quantidade e o preço total do carrinho automaticamente sempre que o vetor interno sofrer alterações.
🧠 O que é o computed() do Vue 3?
Uma propriedade computada rastreia dinamicamente suas dependências. Ela possui duas características excelentes para Engenharia de Software:
- Cache Inteligente: Se a tela re-renderizar por outro motivo qualquer, ela não refaz a conta, economizando processamento e bateria do dispositivo do usuário.
- Reatividade em Cadeia: Quando o array de itens muda, os totais mudam instantaneamente na tela sem nenhuma intervenção imperativa manual.
stateDiagram-v2
[*] --> CarrinhoVazio
CarrinhoVazio --> ComItens : adicionar(Produto)
ComItens --> ComItens : adicionar() / remover()
state ComItens {
[*] --> RecalcularComputeds
RecalcularComputeds --> totalItens
RecalcularComputeds --> valorTotal
}
ComItens --> CarrinhoVazio : limpar()
ComItens --> CheckoutAPI : finalizarPedido()
CheckoutAPI --> CarrinhoVazio : Sucesso
Crie o arquivo em src/composables/useCart.ts:
import { ref, computed } from 'vue';
import api from '../services/api';
import { CartItem, Produto } from '../models';
import { useAuth } from './useAuth';
// Estado global do carrinho
const itens = ref<CartItem[]>([]);
export function useCart() {
const auth = useAuth();
// Getters Computados Reativos (Memoized)
const totalItens = computed(() => itens.value.reduce((acc, item) => acc + item.quantidade, 0));
const valorTotal = computed(() => itens.value.reduce((acc, item) => acc + (item.preco * item.quantidade), 0));
const cartItens = computed(() => itens.value);
const adicionar = (produto: Produto) => {
if (!produto.id) return;
const itemExistente = itens.value.find(i => i.produto_id === produto.id);
if (itemExistente) {
itemExistente.quantidade++;
} else {
itens.value.push({
produto_id: produto.id,
nome: produto.nome,
preco: produto.preco,
quantidade: 1
});
}
};
const remover = (produtoId: number) => {
itens.value = itens.value.filter(i => i.produto_id !== produtoId);
};
const limpar = () => {
itens.value = [];
};
// Realiza o checkout integrando diretamente à nossa API FastAPI
const finalizarPedido = async () => {
if (!auth.isAuthenticated.value) {
throw new Error("Você precisa estar autenticado para finalizar a compra.");
}
// Estrutura o DTO idêntico ao esperado pelo Schema Pydantic v2 do backend
const pedidoForm = {
cliente_id: 1, // ID fixado do seeder 'Maria Silva'
itens: itens.value.map(i => ({
produto_id: i.produto_id,
quantidade: i.quantidade
}))
};
await api.post('/pedidos', pedidoForm);
limpar(); // Esvazia o carrinho apenas em caso de sucesso transacional na API
};
return {
cartItens,
totalItens,
valorTotal,
adicionar,
remover,
limpar,
finalizarPedido
};
}
🛍️ 2. Vitrine: O Catálogo de Produtos (Catalogo.vue)
Esta view exibe a barra de busca ligada reativamente a uma variável via v-model e a vitrine de eletrônicos disposta em um grid responsivo em Glassmorphism.
Crie o arquivo em src/views/Catalogo.vue:
<template>
<nav class="navbar">
<router-link to="/" class="navbar-brand">🛒 TecLoja 02</router-link>
<ul class="navbar-menu">
<li><router-link to="/" class="navbar-link active">Catálogo</router-link></li>
<li><router-link to="/carrinho" class="navbar-link">Carrinho ({{ cart.totalItens.value }})</router-link></li>
<li v-if="auth.isAdmin.value"><router-link to="/admin/produtos" class="navbar-link">Painel Admin</router-link></li>
<li v-if="!auth.isAuthenticated.value"><router-link to="/login" class="btn btn-primary">Login</router-link></li>
<li v-else>
<span style="margin-right: 15px; color: var(--text-secondary);">Olá, {{ auth.currentUser.value?.username }}</span>
<button class="btn btn-danger" @click="auth.logout()">Sair</button>
</li>
</ul>
</nav>
<div style="padding: 2rem 5%;">
<!-- Filtros de Pesquisa -->
<div class="glass-panel" style="margin-bottom: 2rem; display: flex; gap: 1rem; flex-wrap: wrap;">
<input type="text" class="form-control" style="flex: 2; min-width: 250px;"
placeholder="Busque eletrônicos premium..." v-model="busca" @input="pesquisar">
</div>
<!-- Grid de Produtos -->
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 2rem;">
<div class="glass-panel" v-for="p in produtos" :key="p.id">
<div style="height: 150px; background: rgba(255,255,255,0.05); border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-bottom: 1rem;">
<span style="font-size: 3rem;">💻</span>
</div>
<h3 style="margin-bottom: 0.5rem; font-size: 1.25rem;">{{ p.nome }}</h3>
<p style="color: var(--text-secondary); font-size: 0.875rem; min-height: 40px; margin-bottom: 1rem;">{{ p.descricao }}</p>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.25rem;">
<span style="font-size: 1.5rem; font-weight: 700; color: var(--accent-color);">
R$ {{ p.preco.toLocaleString('pt-BR', { minimumFractionDigits: 2 }) }}
</span>
<span :style="{ color: p.estoque > 0 ? 'var(--success-color)' : 'var(--danger-color)' }" style="font-size: 0.875rem; font-weight: 600;">
{{ p.estoque > 0 ? p.estoque + ' em estoque' : 'Esgotado' }}
</span>
</div>
<button class="btn btn-primary" style="width: 100%; justify-content: center;"
:disabled="p.estoque <= 0" @click="cart.adicionar(p)">
Adicionar ao Carrinho
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../services/api';
import { Produto } from '../models';
import { useCart } from '../composables/useCart';
import { useAuth } from '../composables/useAuth';
const cart = useCart();
const auth = useAuth();
const produtos = ref<Produto[]>([]);
const busca = ref('');
const carregarProdutos = async () => {
try {
const res = await api.get<Produto[]>('/produtos');
produtos.value = res.data;
} catch (err) {
console.error("Erro ao carregar catálogo", err);
}
};
const pesquisar = async () => {
if (!busca.value.trim()) {
carregarProdutos();
return;
}
try {
const res = await api.get<Produto[]>(`/produtos/pesquisa/?nome=${busca.value}`);
produtos.value = res.data;
} catch (err) {
console.error("Erro na busca", err);
}
};
onMounted(carregarProdutos);
</script>
🏢 3. Painel Administrativo de Controle (AdminProdutos.vue)
Exibe uma tabela robusta que consome as requisições protegidas da API, permitindo exclusão direta do produto no banco.
Crie o arquivo em src/views/admin/AdminProdutos.vue:
<template>
<nav class="navbar">
<router-link to="/" class="navbar-brand">🛒 TecLoja Admin</router-link>
<router-link to="/" class="btn btn-danger">Sair do Painel</router-link>
</nav>
<div style="padding: 2rem 5%;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h2>Gestão de Produtos do Catálogo</h2>
<router-link to="/admin/produtos/novo" class="btn btn-primary">+ Cadastrar Novo Eletrônico</router-link>
</div>
<div class="glass-panel" style="overflow-x: auto; padding: 0;">
<table style="width: 100%; border-collapse: collapse; text-align: left;">
<thead>
<tr style="border-bottom: 1px solid var(--border-color); background: rgba(0,0,0,0.1);">
<th style="padding: 1rem;">ID</th>
<th style="padding: 1rem;">Nome</th>
<th style="padding: 1rem;">Estoque</th>
<th style="padding: 1rem;">Preço</th>
<th style="padding: 1rem; text-align: center;">Ações</th>
</tr>
</thead>
<tbody>
<tr v-for="p in produtos" :key="p.id" style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 1rem;">{{ p.id }}</td>
<td style="padding: 1rem; font-weight: 600;">{{ p.nome }}</td>
<td style="padding: 1rem;">{{ p.estoque }} unid.</td>
<td style="padding: 1rem; color: var(--accent-color); font-weight: 700;">
R$ {{ p.preco.toLocaleString('pt-BR', { minimumFractionDigits: 2 }) }}
</td>
<td style="padding: 1rem; display: flex; gap: 0.5rem; justify-content: center;">
<router-link :to="'/admin/produtos/editar/' + p.id" class="btn btn-primary" style="padding: 0.5rem 1rem;">Editar</router-link>
<button class="btn btn-danger" style="padding: 0.5rem 1rem;" @click="deletar(p.id!)">Excluir</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import api from '../../services/api';
import { Produto } from '../../models';
const produtos = ref<Produto[]>([]);
const carregarProdutos = async () => {
const res = await api.get<Produto[]>('/produtos');
produtos.value = res.data;
};
const deletar = async (id: number) => {
if (confirm("Deseja realmente remover este eletrônico do catálogo?")) {
try {
await api.delete(`/produtos/${id}`);
produtos.value = produtos.value.filter(p => p.id !== id);
} catch (err: any) {
alert("Falha ao apagar produto: " + err.response?.data?.detail);
}
}
};
onMounted(carregarProdutos);
</script>
📝 4. Formulário Administrativo Validado (ProdutoForm.vue)
Este componente opera de forma inteligente em dois modos (criação e edição). Ele valida reativamente se os campos obrigatórios estão preenchidos antes de permitir salvar.
Crie o arquivo em src/views/admin/ProdutoForm.vue:
<template>
<nav class="navbar">
<span class="navbar-brand">🛒 Formulário TecLoja</span>
</nav>
<div style="max-width: 600px; margin: 3rem auto; padding: 0 1rem;">
<div class="glass-panel">
<h2 style="margin-bottom: 2rem;">{{ id ? 'Editar Eletrônico' : 'Cadastrar Novo Eletrônico' }}</h2>
<form @submit.prevent="salvar">
<div class="form-group">
<label class="form-label">Nome do Eletrônico</label>
<input type="text" class="form-control" v-model="produto.nome" required @blur="erros.nome = !produto.nome">
<span v-if="erros.nome" style="color: var(--danger-color); font-size: 0.8rem;">O nome é obrigatório.</span>
</div>
<div class="form-group">
<label class="form-label">Descrição Técnica</label>
<textarea class="form-control" rows="3" v-model="produto.descricao"></textarea>
</div>
<div style="display: flex; gap: 1rem;">
<div class="form-group" style="flex: 1;">
<label class="form-label">Preço (R$)</label>
<input type="number" step="0.01" class="form-control" v-model="produto.preco" required>
</div>
<div class="form-group" style="flex: 1;">
<label class="form-label">Estoque Físico</label>
<input type="number" class="form-control" v-model="produto.estoque" required>
</div>
</div>
<div class="form-group">
<label class="form-label">Categoria (1=Smartphones, 2=Notebooks, 3=Acessórios)</label>
<input type="number" class="form-control" v-model="produto.categoria_id" required>
</div>
<div style="display: flex; justify-content: flex-end; gap: 1rem; margin-top: 2rem;">
<router-link to="/admin/produtos" class="btn btn-danger">Cancelar</router-link>
<button type="submit" class="btn btn-primary" :disabled="!isFormValid">Salvar Alterações</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../../services/api';
import { Produto } from '../../models';
const router = useRouter();
// Define a prop recebida dinamicamente do roteador
const props = defineProps<{
id?: string;
}>();
const produto = ref<Produto>({
nome: '',
descricao: '',
preco: 0.01,
estoque: 0,
categoria_id: 1
});
const erros = ref({
nome: false
});
// Validação reativa sem bibliotecas extras (Didático e eficiente)
const isFormValid = computed(() => {
return produto.value.nome.trim().length >= 3 &&
produto.value.preco > 0 &&
produto.value.estoque >= 0 &&
produto.value.categoria_id >= 1;
});
const carregarProdutoEdicao = async (id: number) => {
try {
const res = await api.get<Produto>(`/produtos/${id}`);
produto.value = res.data;
} catch (err) {
alert("Falha ao obter produto.");
}
};
const salvar = async () => {
if (!isFormValid.value) return;
try {
if (props.id) {
await api.put(`/produtos/${props.id}`, produto.value);
} else {
await api.post('/produtos', produto.value);
}
router.push('/admin/produtos');
} catch (err: any) {
alert("Erro ao salvar produto: " + err.response?.data?.message);
}
};
onMounted(() => {
if (props.id) {
carregarProdutoEdicao(Number(props.id));
}
});
</script>
✅ Pré-Requisitos deste Módulo
Antes de avançar para a parte de infraestrutura, testes e DevOps, certifique-se de que:
- A base de roteamento, estilos premium de Glassmorphism e interceptadores de token JWT foram criados nos Módulos 06 e 07.
- A API REST FastAPI está rodando localmente (com o banco Neon PostgreSQL) para responder às chamadas de consulta e alteração do catálogo.
🤔 Por que fizemos assim?
- Por que declarar o array de itens do carrinho fora da função de retorno do Composable
useCart? Ao definirconst itens = ref<CartItem[]>([])no escopo do arquivo (fora da função exportadauseCart), criamos uma única instância de estado global na memória da aplicação (padrão Singleton). Isso garante que qualquer componente que invoqueuseCart()(seja o ícone de quantidade na Navbar, a vitrine ou o formulário de checkout) acesse e altere exatamente o mesmo carrinho de compras unificado. - Por que usar propriedades Computadas (
computed) para calcular totais do carrinho? As propriedades computadas realizam cache de seus resultados baseado em suas dependências reativas. O Vue só reexecutará as lógicas de soma e cálculo de preço total se o arrayitens.valuesofrer alguma mutação. Se o catálogo re-renderizar por causa de uma busca textual, o cálculo do carrinho é poupado, poupando processamento de CPU e bateria do dispositivo móvel do cliente. - Por que usar o mesmo formulário (
ProdutoForm.vue) para criar e editar produtos? Duplicar código criando formulários separados para cadastro e edição introduz inconsistências futuras de UI e regras de negócio. Ao expor a propriedadeiddinamicamente como prop vinda do router, o componente assume inteligentemente o modo “Editar” seidexistir (buscando as informações existentes noonMountede enviando viaPUT) ou modo “Cadastrar” se for nulo (iniciando o formulário limpo e salvando viaPOST).
🔍 Checkpoint
- Reatividade na Vitrine: Abra o catálogo de produtos e adicione itens. Confirme se o contador do carrinho na Navbar incrementa em tempo real de forma automática.
- Persistência Visual de Admin: Acesse o painel administrativo do catálogo, insira um novo eletrônico de teste e clique em Salvar. Verifique se o produto é listado na tabela de controle e reaparece na vitrine pública de vendas.
- Segurança em Endpoints: Tente excluir um produto pelo painel de controle e veja no console de rede se a requisição DELETE foi enviada com sucesso acompanhada do token JWT nos headers.
⚠️ Erros Comuns
| Erro | Causa | Solução |
|---|---|---|
| Totais do carrinho não atualizam após adicionar produtos | O desenvolvedor reatribuiu o array do carrinho bruto (itens = []) quebrando o proxy de reatividade do Vue. |
Lembre-se de que variáveis declaradas via ref devem ter seus valores mutados usando sempre a propriedade .value (ex: itens.value = []). |
Erro 422 Unprocessable Entity ao cadastrar produto |
A tipagem de dados enviada no formulário de cadastro difere do esperado pela validação de tipos do Pydantic v2 no FastAPI (ex: enviar categoria_id como String em vez de Int). |
Certifique-se de que os dados numéricos estão sendo parseados ou vinculados como inteiros na diretiva de input usando modificadores adequados (v-model.number="produto.preco"). |
| O formulário do produto exibe tela vazia ou falha na edição | A prop id passada pela rota dinâmica foi enviada como String e a requisição na API falhou ao tentar realizar a busca de chave primária. |
Faça a coerção de tipo do parâmetro da rota de string para inteiro na chamada do método de carregamento: carregarProdutoEdicao(Number(props.id)). |
🏁 Conclusão do Frontend
Incrível! Toda a interface do cliente SPA da TecLoja 02 foi construída usando o Vue 3. Você programou um fluxo completo reativo de compras com composables (useCart) e propriedades computadas, acoplou rotas seguras e integrou formulários à nossa API FastAPI assíncrona.
No Módulo 09 final, consolidaremos a cultura de DevOps em nuvem! Escreveremos testes unitários com pytest, criaremos os Dockerfiles Multi-stage de produção de ambas as tecnologias, configuraremos pipelines de CI/CD no GitHub Actions para deploys isolados da Render e Netlify, e entregaremos um checklist autoguiado para diagnosticar toda a integridade da aplicação!