📚 Módulo 08: Frontend - Carrinho e CRUD com Signals

✅ Pré-Requisitos deste Módulo

Confirme antes de começar:

  1. Backend rodando: ./mvnw spring-boot:run (pasta backend/)
  2. Frontend compilando sem erros: npm start (pasta web/)
  3. Login funcionando em http://localhost:4200/login

Você deve ter concluído o Módulo 07 (AuthService, adminGuard e authInterceptor criados).

Neste módulo prático do frontend, desenvolveremos os componentes interativos do nosso e-commerce. Construiremos um serviço de Carrinho de Compras reativo fazendo uso avançado de Signals Computados (computed), estruturaremos a vitrine do Catálogo de Eletrônicos e programaremos formulários reativos robustos para a área administrativa de CRUD de Produtos.


🛒 1. Carrinho de Compras Reativo com computed()

Ao gerenciar um carrinho de compras, precisamos reajustar o preço total e a contagem de itens em tempo real a cada clique. Usaremos a função computed() do Angular para isso.

🧠 O que é um computed() Signal?

Um Signal computado é derivado de outro Signal (nosso vetor de itens no carrinho). Ele possui duas características fantásticas:

stateDiagram-v2
    [*] --> CarrinhoVazio
    CarrinhoVazio --> StateWritables : adicionar(Produto)
    StateWritables --> StateWritables : itens.update()
    
    state StateWritables {
        [*] --> ComputedSignals
        ComputedSignals --> totalItens()
        ComputedSignals --> valorTotal()
    }
    
    StateWritables --> CarrinhoVazio : limpar()
    StateWritables --> CheckoutAPI : finalizarPedido()
    CheckoutAPI --> CarrinhoVazio : Sucesso

Crie no arquivo src/app/services/carrinho.service.ts:

import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Produto } from '../models/produto.model';
import { ItemPedidoForm } from '../models/item-pedido.model';
import { AuthService } from './auth.service';
import { tap } from 'rxjs';

export interface CartItem {
  produtoId: number;
  nome: string;
  preco: number;
  quantidade: number;
}

@Injectable({
  providedIn: 'root'
})
export class CarrinhoService {
  private http = inject(HttpClient);
  private authService = inject(AuthService);
  private apiUrl = 'http://localhost:8080/api/v1/pedidos';

  // Signal Writable com a lista de itens
  itens = signal<CartItem[]>([]);

  // Signals Computados para recalcular totais de forma super-eficiente
  totalItens = computed(() => this.itens().reduce((acc, item) => acc + item.quantidade, 0));
  
  valorTotal = computed(() => this.itens().reduce((acc, item) => acc + (item.preco * item.quantidade), 0));

  adicionar(produto: Produto): void {
    if (!produto.id) return;
    
    this.itens.update(currentItens => {
      const itemExistente = currentItens.find(item => item.produtoId === produto.id);
      
      if (itemExistente) {
        return currentItens.map(item => 
          item.produtoId === produto.id 
            ? { ...item, quantidade: item.quantidade + 1 }
            : item
        );
      }
      
      return [...currentItens, { produtoId: produto.id!, nome: produto.nome, preco: produto.preco, quantidade: 1 }];
    });
  }

  remover(produtoId: number): void {
    this.itens.update(currentItens => currentItens.filter(item => item.produtoId !== produtoId));
  }

  limpar(): void {
    this.itens.set([]);
  }

  // Realiza o checkout integrando diretamente ao backend
  finalizarPedido() {
    const user = this.authService.currentUser();
    if (!user) throw new Error("Usuário não autenticado");

    // Constrói o DTO de formulário de pedido esperado pelo backend
    const pedidoForm = {
      clienteId: 1, // Fixado id didático do cliente usuario@email.com criado pelo Seeder
      itens: this.itens().map(item => ({
        produtoId: item.produtoId,
        quantidade: item.quantidade
      }))
    };

    return this.http.post(this.apiUrl, pedidoForm).pipe(
      tap(() => this.limpar()) // Limpa o carrinho após sucesso no checkout
    );
  }
}

🛒 2. A Tela do Carrinho (src/app/components/carrinho/carrinho.component.ts)

Com o serviço pronto, criamos o componente visual que renderiza os itens do carrinho e o botão de finalização de compra.

import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router, RouterLink } from '@angular/router';
import { CarrinhoService } from '../../services/carrinho.service';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-carrinho',
  standalone: true,
  imports: [CommonModule, RouterLink],
  template: `
    <nav class="navbar">
      <a class="navbar-brand" routerLink="/">🛒 TecLoja</a>
      <ul class="navbar-menu">
        <li><a class="navbar-link" routerLink="/">← Continuar Comprando</a></li>
      </ul>
    </nav>

    <div style="max-width: 900px; margin: 3rem auto; padding: 0 1rem;">
      <h2 style="margin-bottom: 2rem;">Seu Carrinho</h2>

      <ng-container *ngIf="carrinho.itens().length > 0; else carrinhoVazio">
        <!-- Lista de Itens -->
        <div class="glass-panel" *ngFor="let item of carrinho.itens()"
             style="display: flex; justify-content: space-between; align-items: center;
                    margin-bottom: 1rem; padding: 1.25rem 1.5rem;">
          <div>
            <h3 style="font-size: 1.1rem; margin-bottom: 0.25rem;">{{ item.nome }}</h3>
            <span style="color: var(--text-secondary); font-size: 0.85rem;">
              {{ item.preco | currency:'BRL' }} × {{ item.quantidade }}
            </span>
          </div>
          <div style="display: flex; align-items: center; gap: 1.5rem;">
            <span style="font-weight: 700; font-size: 1.2rem; color: var(--accent-color);">
              {{ (item.preco * item.quantidade) | currency:'BRL' }}
            </span>
            <button class="btn btn-danger" style="padding: 0.4rem 0.8rem; font-size: 0.8rem;"
                    (click)="carrinho.remover(item.produtoId)">Remover</button>
          </div>
        </div>

        <!-- Resumo e Checkout -->
        <div class="glass-panel" style="margin-top: 2rem; text-align: right;">
          <p style="font-size: 1.1rem; margin-bottom: 1.5rem;">
            Total: <strong style="font-size: 1.5rem; color: var(--accent-color);">
              {{ carrinho.valorTotal() | currency:'BRL' }}
            </strong>
          </p>

          <div *ngIf="errorMessage()" style="color: var(--danger-color); margin-bottom: 1rem; text-align: left;">
            ⚠️ {{ errorMessage() }}
          </div>

          <button class="btn btn-primary" (click)="finalizarCompra()" [disabled]="loading()">
            {{ loading() ? 'Faturando...' : 'Finalizar Compra' }}
          </button>
        </div>
      </ng-container>

      <ng-template #carrinhoVazio>
        <div class="glass-panel" style="text-align: center; padding: 4rem;">
          <p style="font-size: 1.2rem; color: var(--text-secondary); margin-bottom: 1.5rem;">
            Seu carrinho está vazio.
          </p>
          <a class="btn btn-primary" routerLink="/">Ver Catálogo</a>
        </div>
      </ng-template>
    </div>
  `
})
export class CarrinhoComponent {
  carrinho = inject(CarrinhoService);
  auth    = inject(AuthService);
  router  = inject(Router);

  loading      = signal(false);
  errorMessage = signal<string | null>(null);

  finalizarCompra(): void {
    if (!this.auth.isAuthenticated()) {
      this.router.navigate(['/login']);
      return;
    }

    this.loading.set(true);
    this.errorMessage.set(null);

    this.carrinho.finalizarPedido().subscribe({
      next: () => {
        this.loading.set(false);
        alert('🎉 Pedido faturado com sucesso! Estoque atualizado.');
        this.router.navigate(['/']);
      },
      error: (err) => {
        this.loading.set(false);
        // Trata a resposta de erro RFC 7807 (ProblemDetail) do Spring
        this.errorMessage.set(err.error?.detail ?? 'Erro ao finalizar compra. Tente novamente.');
      }
    });
  }
}

[!NOTE] Por que usamos signal<string | null>(null) para errorMessage? Um Signal simples é suficiente aqui — null representa “sem erro” e qualquer string representa a mensagem de falha. Evitamos usar BehaviorSubject do RxJS porque Signals são mais diretos para estado local de componente: sem necessidade de subscribe, sem vazamento de memória e leitura síncrona com errorMessage().


🛍️ 4. Vitrine: O Catálogo de Produtos

Criaremos a vitrine responsiva em src/app/components/catalogo/catalogo.component.ts. Ela consumirá a API de busca de produtos e disponibilizará filtros rápidos por nome e botões de compra.

import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink, Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { Produto } from '../../models/produto.model';
import { CarrinhoService } from '../../services/carrinho.service';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-catalogo',
  standalone: true,
  imports: [CommonModule, FormsModule, RouterLink],
  template: `
    <nav class="navbar">
      <a class="navbar-brand" routerLink="/">🛒 TecLoja</a>
      <ul class="navbar-menu">
        <li><a class="navbar-link active" routerLink="/">Catálogo</a></li>
        <li><a class="navbar-link" routerLink="/carrinho">Carrinho ({{ carrinho.totalItens() }})</a></li>
        
        <li *ngIf="auth.isAdmin()"><a class="navbar-link" routerLink="/admin/produtos">Painel Admin</a></li>
        
        <li *ngIf="!auth.isAuthenticated()"><a class="btn btn-primary" routerLink="/login">Login</a></li>
        <li *ngIf="auth.isAuthenticated()">
          <span style="margin-right: 10px; color: var(--text-secondary);">Olá, {{ auth.currentUser()?.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..." [(ngModel)]="busca" (input)="pesquisar()">
      </div>

      <!-- Grid de Produtos Responsivo em Glassmorphism -->
      <div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 2rem;">
        <div class="glass-panel product-card" *ngFor="let p of produtos()">
          <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);">{{ p.preco | currency:'BRL' }}</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)="adicionarAoCarrinho(p)">
            Adicionar ao Carrinho
          </button>
        </div>
      </div>
    </div>
  `
})
export class CatalogoComponent implements OnInit {
  private http = inject(HttpClient);
  carrinho = inject(CarrinhoService);
  auth = inject(AuthService);

  produtos = signal<Produto[]>([]);
  busca = '';

  ngOnInit(): void {
    this.carregarProdutos();
  }

  carregarProdutos(): void {
    this.http.get<Produto[]>('http://localhost:8080/api/v1/produtos').subscribe({
      next: data => this.produtos.set(data),
      error: err => console.error("Erro ao obter catálogo", err)
    });
  }

  pesquisar(): void {
    if (!this.busca.trim()) {
      this.carregarProdutos();
      return;
    }
    this.http.get<Produto[]>(`http://localhost:8080/api/v1/produtos/pesquisa?nome=${this.busca}`).subscribe({
      next: data => this.produtos.set(data)
    });
  }

  adicionarAoCarrinho(produto: Produto): void {
    this.carrinho.adicionar(produto);
  }
}

🏢 5. CRUD Administrativo de Produtos

O painel administrativo restrito aos usuários autenticados com permissão ROLE_ADMIN fará o gerenciamento completo do catálogo.

1. Lista de Gestão (src/app/components/admin-produtos/admin-produtos.component.ts)

import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { Produto } from '../../models/produto.model';

@Component({
  selector: 'app-admin-produtos',
  standalone: true,
  imports: [CommonModule, RouterLink],
  template: `
    <nav class="navbar">
      <a class="navbar-brand" routerLink="/">🛒 TecLoja Admin</a>
      <button class="btn btn-danger" routerLink="/">Sair do Painel</button>
    </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>
        <button class="btn btn-primary" routerLink="/admin/produtos/novo">+ Cadastrar Novo Eletrônico</button>
      </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 *ngFor="let p of produtos()" style="border-bottom: 1px solid var(--border-color); transition: background 0.2s;">
              <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;">{{ p.preco | currency:'BRL' }}</td>
              <td style="padding: 1rem; display: flex; gap: 0.5rem; justify-content: center;">
                <button class="btn btn-primary" style="padding: 0.5rem 1rem;" [routerLink]="['/admin/produtos/editar', p.id]">Editar</button>
                <button class="btn btn-danger" style="padding: 0.5rem 1rem;" (click)="deletar(p.id!)">Excluir</button>
              </td>
            </tr>
          </tbody>
        </table>
      </div>
    </div>
  `
})
export class AdminProdutosComponent implements OnInit {
  private http = inject(HttpClient);
  produtos = signal<Produto[]>([]);

  ngOnInit(): void {
    this.carregarProdutos();
  }

  carregarProdutos(): void {
    this.http.get<Produto[]>('http://localhost:8080/api/v1/produtos').subscribe(data => this.produtos.set(data));
  }

  deletar(id: number): void {
    if (confirm("Deseja realmente excluir este produto da TecLoja?")) {
      this.http.delete(`http://localhost:8080/api/v1/produtos/${id}`).subscribe(() => {
        this.produtos.update(current => current.filter(p => p.id !== id));
      });
    }
  }
}

2. Formulário Reativo de Cadastro/Edição (src/app/components/produto-form/produto-form.component.ts)

Ensina a construir formulários acoplados à validação do Angular, oferecendo uma UI rica com alertas dinâmicos de preenchimento correto.

import { Component, OnInit, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { Produto } from '../../models/produto.model';

@Component({
  selector: 'app-produto-form',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, RouterLink],
  template: `
    <nav class="navbar">
      <a class="navbar-brand" routerLink="/">🛒 Formulário TecLoja</a>
    </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 [formGroup]="form" (ngSubmit)="salvar()">
          <div class="form-group">
            <label class="form-label">Nome do Eletrônico</label>
            <input type="text" class="form-control" formControlName="nome">
            <span *ngIf="isInvalid('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" formControlName="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" class="form-control" formControlName="preco">
              <span *ngIf="isInvalid('preco')" style="color: var(--danger-color); font-size: 0.8rem;">Deve ser maior que zero.</span>
            </div>

            <div class="form-group" style="flex: 1;">
              <label class="form-label">Estoque Físico</label>
              <input type="number" class="form-control" formControlName="estoque">
              <span *ngIf="isInvalid('estoque')" style="color: var(--danger-color); font-size: 0.8rem;">Não pode ser negativo.</span>
            </div>
          </div>

          <div class="form-group">
            <label class="form-label">ID da Categoria (1=Smartphones, 2=Notebooks, 3=Acessórios)</label>
            <input type="number" class="form-control" formControlName="categoriaId">
            <span *ngIf="isInvalid('categoriaId')" style="color: var(--danger-color); font-size: 0.8rem;">Escolha uma categoria válida.</span>
          </div>

          <div style="display: flex; justify-content: flex-end; gap: 1rem; margin-top: 2rem;">
            <button type="button" class="btn btn-danger" routerLink="/admin/produtos">Cancelar</button>
            <button type="submit" class="btn btn-primary" [disabled]="form.invalid">Salvar Alterações</button>
          </div>
        </form>
      </div>
    </div>
  `
})
export class ProdutoFormComponent implements OnInit {
  private fb = inject(FormBuilder);
  private http = inject(HttpClient);
  private router = inject(Router);

  // Parâmetro dinâmico de rota bindado automaticamente pelo Router do Angular 18+
  @Input() id?: string;

  form!: FormGroup;

  ngOnInit(): void {
    this.form = this.fb.group({
      nome: ['', [Validators.required]],
      descricao: [''],
      preco: [0, [Validators.required, Validators.min(0.01)]],
      estoque: [0, [Validators.required, Validators.min(0)]],
      categoriaId: [1, [Validators.required, Validators.min(1)]]
    });

    if (this.id) {
      this.carregarProdutoEdicao(Number(this.id));
    }
  }

  carregarProdutoEdicao(id: number): void {
    this.http.get<Produto>(`http://localhost:8080/api/v1/produtos/${id}`).subscribe(p => {
      this.form.patchValue(p);
    });
  }

  isInvalid(controlName: string): boolean {
    const control = this.form.get(controlName);
    return control ? control.invalid && control.touched : false;
  }

  salvar(): void {
    if (this.form.invalid) return;

    const payload = this.form.value;

    if (this.id) {
      this.http.put(`http://localhost:8080/api/v1/produtos/${this.id}`, payload).subscribe({
        next: () => this.router.navigate(['/admin/produtos']),
        error: err => alert("Erro ao editar produto: " + err.error.message)
      });
    } else {
      this.http.post('http://localhost:8080/api/v1/produtos', payload).subscribe({
        next: () => this.router.navigate(['/admin/produtos']),
        error: err => alert("Erro ao cadastrar produto: " + err.error.message)
      });
    }
  }
}

🤔 Por que computed() em vez de calcular totais diretamente no template?

Calcular itens().reduce(...) diretamente no template Angular faria o cálculo ser executado a cada ciclo de detecção de mudança, mesmo quando o carrinho não mudou. Um computed() Signal é memoizado: ele recalcula somente quando o Signal de origem (itens) muda, e retorna o valor em cache para todas as outras leituras. Em telas com muitos componentes, isso evita gargalos de performance.

[!NOTE] Nota sobre o clienteId: 1 fixado no CarrinhoService

O clienteId está fixado como 1 por simplificação didática — assume-se que o DataSeeder sempre cria o usuário padrão com ID 1. Em um sistema de produção real, o ID do cliente seria obtido dinamicamente do token JWT decodificado ou de um endpoint /api/v1/perfil. Esta limitação é intencional e documentada no código.

🔍 Checkpoint

Com o backend e o frontend rodando, teste o fluxo completo:

  1. Acesse http://localhost:4200
  2. O catálogo deve exibir os 6 produtos do DataSeeder (iPhone, Samsung, MacBook, etc.)
  3. Clique em “Adicionar ao Carrinho” em qualquer produto 3 vezes
  4. Olhe a Navbar — o badge deve mostrar Carrinho (3) sem recarregar a página
  5. Acesse http://localhost:4200/carrinho — os itens devem estar listados com o valor total

Resultado esperado: Carrinho reativo funcionando, total calculado automaticamente.

Para testar o painel admin:

  1. Faça login com admin@tecloja.com / admin123
  2. Acesse /admin/produtos — tabela com os 6 produtos deve aparecer
  3. Clique em Editar em qualquer produto e altere o preço — deve salvar via PUT e retornar à lista

⚠️ Erros Comuns

Sintoma Causa Solução
Catálogo não carrega produtos (lista vazia) Backend não está rodando ou URL da API errada Confirme ./mvnw spring-boot:run ativo e que carregarProdutos() aponta para http://localhost:8080/api/v1/produtos
*ngFor não funciona (template error) CommonModule não importado no componente Adicione CommonModule nos imports do componente standalone
Badge do carrinho não atualiza Signal não está sendo consumido no template Use {{ carrinho.totalItens() }} com parênteses — signals são funções
Checkout retorna 403 Forbidden Token de usuario@email.com não tem permissão para /api/v1/pedidos O endpoint de pedidos requer ROLE_USER. Certifique-se de fazer login com usuario@email.com (não admin) para finalizar compras
Formulário admin retorna 400 ao salvar categoriaId inválido no formulário As IDs das categorias no seeder são 1 (Smartphones), 2 (Notebooks) e 3 (Acessórios)

🏁 Conclusão do Frontend

Sensacional! Toda a interface do cliente SPA da TecLoja foi construída. Você programou um fluxo completo reativo de compras com Signals e implementou formulários validados conectados à nossa API.

No Módulo 09 final, consolidaremos o deploy em nuvem do nosso projeto multirepo! Criaremos os pipelines do GitHub Actions para automatizar as esteiras de CI/CD para a Render (API) e Netlify (SPA), e disponibilizaremos um checklist passo a passo autoguiado para que os alunos possam validar e testar a corretude e o alinhamento pedagógico do seu software!


📬 Entrega da Atividade via Microsoft Teams

Como você acaba de finalizar toda a construção do Frontend Web, este é o momento de fazer o envio do repositório para avaliação!

  1. Garanta que todo o código da pasta web foi enviado (pushed) para um repositório público no seu GitHub pessoal.
  2. Abra o Microsoft Teams na aba de Tarefas/Assignments da disciplina.
  3. Envie o link do repositório seguindo estritamente o padrão de nomenclatura abaixo no campo de texto ou título da entrega:

Padrão de Nomenclatura:

[TECLOJA_01] - [WEB] - Seu Nome Completo Link: https://github.com/seu-usuario/nome-do-repositorio-web


Voltar para o Sumário