📚 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:

  1. 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.
  2. 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:


🤔 Por que fizemos assim?


🔍 Checkpoint

  1. 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.
  2. 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.
  3. 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!


Voltar para o Sumário