📚 Módulo 08: Frontend - Carrinho e CRUD com Signals
✅ Pré-Requisitos deste Módulo
Confirme antes de começar:
- Backend rodando:
./mvnw spring-boot:run(pastabackend/) - Frontend compilando sem erros:
npm start(pastaweb/) - 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:
- Reatividade Automática: Sempre que o vetor de itens mudar, a contagem e o valor total recalculam automaticamente no DOM.
- Cache Inteligente (Memoization): Ele armazena o resultado em memória. Se a tela atualizar por outro motivo qualquer, ele não refaz a conta, economizando processamento e bateria do dispositivo do usuário.
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)paraerrorMessage? Um Signal simples é suficiente aqui —nullrepresenta “sem erro” e qualquer string representa a mensagem de falha. Evitamos usarBehaviorSubjectdo RxJS porque Signals são mais diretos para estado local de componente: sem necessidade desubscribe, sem vazamento de memória e leitura síncrona comerrorMessage().
🛍️ 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: 1fixado noCarrinhoServiceO
clienteIdestá fixado como1por simplificação didática — assume-se que oDataSeedersempre 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:
- Acesse
http://localhost:4200 - O catálogo deve exibir os 6 produtos do
DataSeeder(iPhone, Samsung, MacBook, etc.) - Clique em “Adicionar ao Carrinho” em qualquer produto 3 vezes
- Olhe a Navbar — o badge deve mostrar
Carrinho (3)sem recarregar a página - 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:
- Faça login com
admin@tecloja.com/admin123 - Acesse
/admin/produtos— tabela com os 6 produtos deve aparecer - Clique em Editar em qualquer produto e altere o preço — deve salvar via
PUTe 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!
- Garanta que todo o código da pasta
webfoi enviado (pushed) para um repositório público no seu GitHub pessoal. - Abra o Microsoft Teams na aba de Tarefas/Assignments da disciplina.
- 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 CompletoLink:https://github.com/seu-usuario/nome-do-repositorio-web