📚 Módulo 08: Frontend - Carrinho com Zustand e Server Actions

Neste módulo, daremos interatividade total à TecLoja 04. No projeto com React e Vite utilizamos Custom Hooks e Context API para o Carrinho. Aqui, adotaremos o Zustand, a biblioteca de gerenciamento de estado client-side predileta da comunidade Next.js devido ao seu peso mínimo e ausência de boilerplate.

Para fechar o pedido de compra, utilizaremos a mágica das Server Actions do Next.js para enviar a transação ao NestJS sem precisarmos criar manualmente rotas de API adicionais.


🐻 1. O Zustand: Estado Simples e Poderoso

stateDiagram-v2
    [*] --> Carrinho
    Carrinho --> ZustandStore : Client Component clica em Comprar
    ZustandStore --> LocalStorage : Middleware persist() nativo
    ZustandStore --> ReactComponents : Atualiza UI reativamente

    state ZustandStore {
        itens
        adicionar()
        remover()
        totalItens()
    }

Instale o Zustand:

npm install zustand

Crie o arquivo em src/store/useCartStore.ts:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export interface ProdutoCart {
  id: number;
  nome: string;
  preco: number;
  quantidade: number;
}

interface CartState {
  itens: ProdutoCart[];
  adicionar: (produto: ProdutoCart) => void;
  remover: (id: number) => void;
  limpar: () => void;
  obterValorTotal: () => number;
}

// O middleware "persist" salva os dados automaticamente no localStorage para não perder o carrinho no F5
export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      itens: [],
      
      adicionar: (produto) => set((state) => {
        const existe = state.itens.find(i => i.id === produto.id);
        if (existe) {
          return { itens: state.itens.map(i => i.id === produto.id ? { ...i, quantidade: i.quantidade + 1 } : i) };
        }
        return { itens: [...state.itens, { ...produto, quantidade: 1 }] };
      }),

      remover: (id) => set((state) => ({
        itens: state.itens.filter(i => i.id !== id)
      })),

      limpar: () => set({ itens: [] }),

      obterValorTotal: () => {
        return get().itens.reduce((total, item) => total + (item.preco * item.quantidade), 0);
      }
    }),
    { name: 'tecloja_cart' }
  )
);

O Componente de Botão de Compra (Client Component)

Lembra da nossa vitrine (src/app/page.tsx) que era um Server Component assíncrono? Não podemos colocar onClick lá. A solução do Next.js é extrair o botão para um pequeno Client Component isolado e importá-lo!

Crie src/components/BotaoComprar.tsx:

"use client"; // Esse arquivo vai para o navegador!
import { useCartStore } from '../store/useCartStore';

export default function BotaoComprar({ produto }: { produto: any }) {
  const adicionar = useCartStore(state => state.adicionar);

  return (
    <button 
      onClick={() => adicionar({ id: produto.id, nome: produto.nome, preco: parseFloat(produto.preco), quantidade: 1 })}
      disabled={produto.estoque <= 0} 
      className="w-full bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 text-white font-medium py-2 rounded-lg transition-colors mt-4"
    >
      Adicionar ao Carrinho
    </button>
  );
}

⚡ 2. Finalizando o Pedido com Server Actions

O checkout precisa se comunicar com a nossa API NestJS (Porta 3000). Em vez de criarmos chamadas complexas com Axios ou Fetch no navegador (expondo lógica para o cliente), o Next 14 lançou as Server Actions.

Server Actions são funções assíncronas declaradas no front-end, mas que só rodam de forma escondida no back-end (Node.js)!

Crie o arquivo src/lib/actions.ts:

"use server"; // A MÁGICA: Esta diretiva avisa o Next.js que esta função NÃO deve ir para o navegador

import { cookies } from 'next/headers';

// Esta função será chamada pelo componente do Carrinho (no Browser)
// Mas o código dela rodará isolado no servidor do Vercel
export async function realizarCheckoutAction(itens: { produtoId: number, quantidade: number }[]) {
  
  // Como estamos no Server-Side, podemos pegar o token seguro com facilidade!
  const token = cookies().get('tecloja_token')?.value;

  const res = await fetch('http://localhost:3000/pedidos/checkout', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      ...(token && { Authorization: `Bearer ${token}` }) // Se houver token, passa para a API
    },
    body: JSON.stringify({
      usuarioId: 1, // Semeado didático
      itens: itens
    })
  });

  if (!res.ok) {
    const errorData = await res.json();
    throw new Error(errorData.message || 'Erro ao processar o faturamento no NestJS');
  }

  return await res.json(); // Pedido confirmado!
}

🛒 3. A Tela do Carrinho e a Chamada da Action

Crie a página em src/app/carrinho/page.tsx:

"use client";
import React, { useState } from 'react';
import { useCartStore } from '../../store/useCartStore';
import { realizarCheckoutAction } from '../../lib/actions';
import Link from 'next/link';

export default function CarrinhoPage() {
  const { itens, remover, limpar, obterValorTotal } = useCartStore();
  const [carregando, setCarregando] = useState(false);
  const [sucesso, setSucesso] = useState(false);
  const [erro, setErro] = useState('');

  const handleCheckout = async () => {
    setCarregando(true);
    setErro('');
    try {
      // O Next.js cria uma chamada RPC automática para executar a Action no Node.js!
      const payload = itens.map(i => ({ produtoId: i.id, quantidade: i.quantidade }));
      await realizarCheckoutAction(payload);
      
      limpar();
      setSucesso(true);
    } catch (e: any) {
      setErro(e.message);
    } finally {
      setCarregando(false);
    }
  };

  if (sucesso) return (
    <div className="min-h-screen flex items-center justify-center bg-slate-900 text-white">
      <div className="bg-slate-800 p-10 rounded-2xl text-center">
        <h2 className="text-3xl text-emerald-400 font-bold mb-4">🎉 Compra Confirmada!</h2>
        <Link href="/" className="text-blue-400 hover:underline">Voltar para o Catálogo</Link>
      </div>
    </div>
  );

  return (
    <div className="min-h-screen bg-slate-900 text-slate-100 p-8">
      <h1 className="text-3xl font-bold mb-8">🛒 Carrinho</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
        <div className="md:col-span-2 space-y-4">
          {itens.length === 0 ? <p>Carrinho Vazio</p> : itens.map(item => (
            <div key={item.id} className="bg-slate-800 p-4 rounded-xl flex justify-between items-center border border-slate-700">
              <div>
                <p className="font-semibold text-lg">{item.nome}</p>
                <p className="text-slate-400 text-sm">Quantidade: {item.quantidade}</p>
              </div>
              <div className="flex items-center gap-4">
                <span className="font-bold text-cyan-400">R$ {item.preco * item.quantidade}</span>
                <button onClick={() => remover(item.id)} className="text-rose-400 hover:text-rose-300">Remover</button>
              </div>
            </div>
          ))}
        </div>

        <div className="bg-slate-800 p-6 rounded-xl border border-slate-700 h-fit">
          <h3 className="text-xl font-bold mb-4">Resumo do Pedido</h3>
          <p className="flex justify-between text-lg mb-6"><span>Total:</span> <span className="font-bold text-cyan-400">R$ {obterValorTotal()}</span></p>
          
          {erro && <p className="text-red-400 text-sm mb-4">{erro}</p>}
          
          <button 
            onClick={handleCheckout} 
            disabled={itens.length === 0 || carregando}
            className="w-full bg-emerald-600 hover:bg-emerald-500 disabled:bg-slate-600 text-white font-bold py-3 rounded-lg transition-colors"
          >
            {carregando ? 'Processando Transação...' : 'Confirmar Compra'}
          </button>
        </div>
      </div>
    </div>
  );
}

✅ Pré-Requisitos deste Módulo

Antes de passar para o último módulo de DevOps, testes unitários no NestJS, Dockerfiles e deploys automatizados, certifique-se de que:


🤔 Por que fizemos assim?


🔍 Checkpoint

  1. Estado Persistido: Adicione produtos ao carrinho no catálogo. Recarregue a página (F5) e confirme se os dados continuam persistidos na tela e na chave tecloja_cart do localStorage do navegador.
  2. Execução da Action: Clique em “Confirmar Compra” na página de carrinho. Valide nas ferramentas de rede do navegador que a chamada disparada não envia cabeçalhos ou dados visíveis para a API NestJS, mas sim para o servidor interno do Next.
  3. Consistência Financeira: Confirme no banco de dados relacional que os estoques correspondentes aos itens comprados foram atômicamente deduzidos após a conclusão do pedido.

⚠️ Erros Comuns

Erro Causa Solução
Erro Hydration failed ao carregar dados do Zustand na inicialização da página O servidor gerou o HTML vazio do carrinho e o cliente tentou injetar os dados salvos do localStorage na montagem física inicial, conflitando as estruturas. Crie um estado local mounted utilizando useState(false) e useEffect(() => setMounted(true), []) no React, e exiba os componentes do Zustand somente se mounted for verdadeiro.
A Server Action falha retornando erro Cookies can only be read from... O desenvolvedor tentou ler ou injetar cabeçalhos de cookies dentro de um componente marcado como Client Component. Certifique-se de que a leitura de cookies (cookies().get(...)) ocorre estritamente dentro de funções Server Components, Route Handlers ou arquivos dedicados declarados com "use server" no topo.
O faturamento falha alegando falta de autorização (401 Unauthorized) O cookie tecloja_token não foi enviado corretamente nas requisições da Server Action para a API NestJS. Verifique se as credenciais foram anexadas de forma automática na chamada interna do fetch usando o cabeçalho Authorization: Bearer <token> extraído do cookie ativo do BFF.

🏁 Conclusão

Sensacional! Utilizamos o Zustand para criar uma persistência de carrinho ultraleve no lado do cliente e fechamos nossa arquitetura com Chave de Ouro invocando uma Server Action. Com as Server Actions, o Next.js lida com a ponte Cliente-Servidor (RPC) de forma invisível, deixando o código absurdamente limpo e isento de “fetchs” verbosos e estados de loading pesados.

No Módulo 09 final, abordaremos o ápice da engenharia: A conteinerização Docker otimizada e o Deploy Automatizado CI/CD para a arquitetura Full-Stack corporativa.


Voltar para o Sumário