Nível 2Unidade 2 · Web dinâmica client-side3 aulas de 50 min + 1 h EADFecha a unidade · Marco 2
Aula 10 — AJAX, JSON e Single Page Application
Nível 2 — Desenvolvimento Web · WebLab
Na aula passada você aprendeu a esperar por um valor que ainda não chegou. Só que o valor não chegava de lugar nenhum: era o seu próprio setTimeout fingindo ser um servidor. Hoje a simulação acaba. O fetch entra em cena, os produtos do Café Cerrado passam a nascer de um arquivo JSON, o formulário de contato ganha destino de verdade e a navegação entre as páginas deixa de recarregar o navegador. No fim da aula você não terá mais um site: terá uma aplicação. E esta é a aula que fecha a Unidade 2.
[ ] Repositório cafe-cerrado com js/dados.js e js/app.js da Aula 09 funcionando, incluindo os estados carregando/sucesso/erro/vazio.
[ ] Bootstrap 5.3 por CDN e css/estilo.css como na Unidade 1, com skip link e foco visível (Aula 06).
[ ] Formulário de contato com validação nativa e mensagens em JavaScript (Aulas 03 e 07).
[ ] Live Server instalado — hoje o http:// deixa de ser recomendação e passa a ser obrigatório.
[ ] Internet funcionando na máquina: vamos chamar duas APIs públicas na internet.
Na aula passada o cardápio passou a chegar "depois": buscarProdutos() devolvia uma Promise, o Promise.all disparava duas buscas em paralelo e a interface aprendeu a mostrar carregando, sucesso, erro e vazio. Hoje trocamos o setTimeout por rede de verdade — e tudo que você escreveu continua valendo, porque o fetch também devolve Promises. Depois disso, juntamos as três páginas do site em uma só e implementamos a navegação sem recarga que define uma SPA.
Até o começo dos anos 2000, toda interação com um site significava uma volta completa ao servidor: você clicava, o navegador pedia uma página HTML inteira, o servidor a montava e devolvia, e a tela piscava em branco antes de redesenhar tudo. Marcar um e-mail como lido? Página nova. Ir para a página 2 de resultados? Página nova. Corrigir um campo do formulário? Página nova — e adeus aos outros campos que você já tinha preenchido.
Isso limitava o que dava para construir na Web. Aplicações "de verdade" eram programas instalados; o navegador servia para ler documentos.
AJAX é a sigla de Asynchronous JavaScript And XML. A ideia: o JavaScript faz requisições HTTP por conta própria, em segundo plano, recebe só os dados de que precisa e atualiza apenas o pedaço do DOM que mudou. A página não recarrega.
É o que acontece quando um e-mail novo aparece na sua caixa sem você fazer nada, quando o feed carrega mais posts ao chegar no fim da rolagem, quando a busca sugere resultados a cada tecla, quando o "curtir" muda de cor antes de qualquer navegação. Todos esses são pedaços de tela sendo atualizados com dados que chegaram por AJAX.
A ferramenta original era o objeto XMLHttpRequest. Ele funciona até hoje, mas é verboso e baseado em callbacks e eventos:
JavaScript
// Como era com XMLHttpRequest — mostrado só para você reconhecer em código antigo.constrequisicao=newXMLHttpRequest();requisicao.open("GET","data/produtos.json");requisicao.onload=function(){if(requisicao.status>=200&&requisicao.status<300){constprodutos=JSON.parse(requisicao.responseText);console.log(produtos.length,"produtos");}else{console.error("Erro HTTP",requisicao.status);}};requisicao.onerror=function(){console.error("Falha de rede");};requisicao.send();
O padrão moderno é a Fetch API, baseada em Promises — exatamente o que você dominou na Aula 09. É o que usaremos daqui em diante.
🧠 Você sabia?
A tecnologia veio antes do nome. A Microsoft criou o componente XMLHTTP por volta de 1999 para o Outlook Web Access — queriam que o webmail parecesse o Outlook instalado. O Mozilla copiou a ideia como XMLHttpRequest, e o Google a levou ao limite no Gmail (2004) e no Google Maps (2005), que arrastava o mapa sem recarregar nada. O nome "AJAX" só apareceu em fevereiro de 2005, num artigo do designer Jesse James Garrett. E a ironia ficou: o "X" era de XML, mas quem venceu como formato de dados foi o JSON — a sigla, porém, já estava na boca do mundo e ninguém mais trocou.
JSON (JavaScript Object Notation) é texto puro com a aparência de um objeto JavaScript. Foi criado por Douglas Crockford no início dos anos 2000 como alternativa enxuta ao XML, e hoje é o formato do tráfego navegador ↔ servidor em praticamente toda API do mundo. Python, Java, PHP, C#, Go: todas as linguagens leem e escrevem JSON.
{"id":1,"nome":"Espresso do Cerrado","preco":6,"disponivel":true,"observacao":null,"tamanhos":["pequeno","médio","grande"],"fornecedor":{"cidade":"Sinop","uf":"MT"}}
Os tipos permitidos são apenas seis: string (sempre com aspas duplas), número, booleano, null, array e objeto. Nada mais.
Parece, mas não é. As diferenças pegam todo mundo pelo menos uma vez:
Em JavaScript é válido
Em JSON é proibido
Consequência
{ nome: "Café" }
chave sem aspas
erro de parse
{ 'nome': 'Café' }
aspas simples
erro de parse
{ "a": 1, }
vírgula sobrando no fim
erro de parse
// comentário
qualquer comentário
erro de parse
{ "f": function () {} }
funções
erro de parse
{ "x": undefined }
undefined
erro de parse
Não existe tipo data em JSON: datas viajam como string (normalmente no formato ISO, "2030-03-15T19:00:00.000Z") e são convertidas para Date no destino, se precisar.
constproduto={id:1,nome:"Espresso do Cerrado",preco:6};consttexto=JSON.stringify(produto);console.log(texto);console.log(typeoftexto);// "string" — pronto para ENVIAR pela redeconstobjeto=JSON.parse(texto);console.log(objeto.nome);// "Espresso do Cerrado"console.log(typeofobjeto);// "object" — pronto para USAR no código
Duas regras para não confundir nunca mais:
stringify = enviar. Vai no body de um POST, ou no localStorage.
parse = receber. Transforma texto em objeto quando os dados chegam como texto.
O stringify aceita um terceiro parâmetro de indentação, muito útil para depurar:
A saída é {"nome":"Café","quando":"2030-03-15T19:00:00.000Z"}. A função e o undefinedsomem sem aviso, e a data virou string. Não é bug: é a especificação. Mas é motivo frequente de "meus dados chegaram incompletos no servidor".
🔬 Investigue
Abra o console e rode JSON.parse('{ "a": 1, }'). Leia a mensagem de erro inteira, com atenção ao número da posição. Agora rode JSON.parse("{ 'a': 1 }") e depois JSON.parse('{ a: 1 }'). Anote as três mensagens: elas são exatamente as que você vai ver quando escrever um .json à mão e esquecer uma vírgula ou uma aspa. Saber traduzir "position 10" para "a décima primeira letra do arquivo" economiza muito tempo.
fetch(url) devolve uma Promise. Ela resolve com um objeto Response — que representa a resposta, não os dados. Extrair os dados é uma segunda operação assíncrona:
JavaScript
asyncfunctionbuscarProdutos(){constresposta=awaitfetch("data/produtos.json");// 1º await: chegaram os cabeçalhosconstprodutos=awaitresposta.json();// 2º await: chegou e foi lido o corporeturnprodutos;}
Por que dois await? Porque uma resposta HTTP chega em duas etapas: primeiro a linha de status e os cabeçalhos (o servidor já pode dizer "200 OK, tipo JSON"), depois o corpo, que pode ser grande e demorar. O fetch te devolve o controle assim que os cabeçalhos chegam, para que você possa decidir se vale a pena ler o corpo.
O objeto Response tem, entre outras, estas propriedades e métodos:
Leia com atenção, porque isso derruba metade de quem está aprendendo:
⚠️ Atenção
A Promise do fetchsó rejeita quando a requisição não acontece: sem internet, DNS que não resolve, CORS bloqueado, URL malformada. Um 404 Not Found ou um 500 Internal Server Error são respostas bem-sucedidas do ponto de vista do fetch — ele conseguiu falar com o servidor, e o servidor respondeu. A Promise resolve normalmente. Se você não testar resposta.ok, seu código vai tentar processar uma página de erro como se fossem dados.
O sintoma clássico dessa falta é uma mensagem que parece não ter nada a ver:
Texto
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
Traduzindo: o servidor devolveu uma página HTML de erro 404, e o .json() tentou interpretar <!DOCTYPE html> como JSON. A causa real está três linhas acima, no fetch sem verificação.
A forma correta:
JavaScript
asyncfunctionbuscarProdutos(){constresposta=awaitfetch("data/produtos.json");if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} ao buscar os produtos`);}returnresposta.json();}
A distinção 4xx × 5xx é prática: 4xx é culpa sua (URL errada, dados inválidos, sem permissão) e você conserta no código; 5xx é culpa do servidor e a única saída do cliente é avisar o usuário e oferecer nova tentativa.
🔎 Por baixo do capô
Abra a aba Network do DevTools, recarregue qualquer página e clique em uma requisição. Em "Headers" você vê exatamente o que foi trocado: o método (GET), a URL, os cabeçalhos de requisição (Accept, User-Agent) e os de resposta (Content-Type, Cache-Control). É o mesmo protocolo HTTP da Aula 01 (§3), só que agora você é quem escreve as requisições. Tudo o que o fetch faz aparece ali — inclusive as que falharam.
Escrever if (!resposta.ok) throw em cada chamada é repetitivo e fácil de esquecer. Concentre em um lugar só:
JavaScript
asyncfunctionpegarJson(url,opcoes={}){constresposta=awaitfetch(url,opcoes);if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} (${resposta.statusText}) em ${url}`);}returnresposta.json();}
Toda chamada do projeto passa por aqui. Se amanhã você precisar acrescentar um cabeçalho de autenticação em todas as requisições — e vai precisar, na Aula 14 —, muda-se uma função só.
Na Aula 09 você viu que Promise.race com um temporizador para de esperar, mas não cancela nada. Com fetch dá para cancelar de verdade:
JavaScript
asyncfunctionpegarJsonComPrazo(url,milissegundos=8000){constcontrolador=newAbortController();consttemporizador=setTimeout(()=>controlador.abort(),milissegundos);try{constresposta=awaitfetch(url,{signal:controlador.signal});if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} em ${url}`);}returnawaitresposta.json();}catch(erro){if(erro.name==="AbortError"){thrownewError(`A resposta demorou mais de ${milissegundos} ms e a requisição foi cancelada`);}throwerro;}finally{clearTimeout(temporizador);}}
O signal é a ponte: quando abort() é chamado, o navegador interrompe a conexão e a Promise do fetch rejeita com um erro cujo name é "AbortError".
O passo mais natural depois da simulação da Aula 09: tirar o array de dentro do JavaScript e colocá-lo em data/produtos.json. Vantagens imediatas: quem edita o cardápio não precisa mexer em código, e o formato é exatamente o que uma API devolveria — quando você construir a sua, na Unidade 3, o front-end nem vai notar a troca.
Abra index.html com dois cliques, direto do gerenciador de arquivos, e o console mostra:
Texto
Access to fetch at 'file:///home/aluno/cafe-cerrado/data/produtos.json' from origin 'null'
has been blocked by CORS policy: Cross origin requests are only supported for
protocol schemes: http, https, ...
A origem de uma página é a combinação de protocolo + domínio + porta (http://127.0.0.1:5500). Páginas abertas como arquivo têm origem null, e o navegador recusa requisições a partir dela por segurança — senão qualquer HTML baixado poderia ler os seus arquivos locais.
A solução é servir o projeto por HTTP: Live Server no VS Code (http://127.0.0.1:5500). Isso vale também para os módulos ES da seção 5.
CORS (Cross-Origin Resource Sharing) é a regra que decide se uma página de uma origem pode ler a resposta de outra origem. Quem decide é o servidor de destino: se ele mandar o cabeçalho Access-Control-Allow-Origin, o navegador libera; se não mandar, o navegador bloqueia a leitura mesmo que a requisição tenha chegado ao destino.
Duas consequências que você vai encontrar hoje:
JSONPlaceholder e ViaCEP mandam esse cabeçalho de propósito, para permitir uso em qualquer página. Por isso funcionam.
Muitos sites não mandam. Tentar fetch("https://www.wikipedia.org") do seu projeto resulta em bloqueio — e não há nada que você possa fazer do lado do cliente. A solução, quando você tiver um servidor próprio, é pedir do back-end.
⚠️ Atenção
Erro de CORS não é um 404 nem um erro do seu código: é o navegador recusando entregar a resposta ao JavaScript. Na aba Network a requisição costuma aparecer como enviada, e mesmo assim o fetch rejeita com TypeError: Failed to fetch. Sempre leia o console inteiro: a mensagem de CORS é longa e explica exatamente qual cabeçalho faltou.
JSONPlaceholder (https://jsonplaceholder.typicode.com) é uma API falsa de treino: devolve posts, comentários, usuários e fotos, e aceita POST, PUT e DELETE fingindo que salvou. Nada é gravado de verdade, o que é perfeito para aprender sem estragar nada.
Detalhe importante do ViaCEP: quando o CEP não existe, ele responde 200 OK com { "erro": "true" }. Ou seja, resposta.ok é true e ainda assim não há endereço. Sempre leia a documentação de cada API — o contrato de erro não é padronizado entre elas.
5. Módulos ES: o código deixa de ser um arquivo só¶
Até a Aula 09, js/dados.js e js/app.js eram carregados com defer e conversavam por variáveis globais. Funciona para dois arquivos; começa a doer no terceiro. Hoje o projeto ganha três arquivos com papéis distintos, e é o momento certo de usar módulos ES.
HTML
<scripttype="module"src="js/app.js"></script>
O que type="module" muda:
Cada arquivo tem escopo próprio: nada vaza para window. Para compartilhar algo, você exporta e importa explicitamente.
A execução é adiada por padrão (comporta-se como defer), então a posição da tag não importa.
import e export passam a funcionar, e a ordem de carregamento é resolvida pelo navegador a partir das dependências.
O modo estrito ("use strict") é sempre ligado.
Só funciona por HTTP — mais um motivo para o Live Server.
// arquivo js/app.js — importa (repare no './' e no '.js' obrigatórios)import{pegarJson,BASE}from"./api.js";
💡 Dica
Ao contrário do Node.js, o navegador exige o caminho completo e relativo: "./api.js", com o ponto inicial e a extensão. Escrever import { pegarJson } from "api.js" resulta em erro, porque sem ./ o navegador acha que é o nome de um pacote.
Juntando fetch + DOM + eventos, chega-se ao padrão que define as aplicações web modernas: carrega-se um HTML; a partir daí o JavaScript intercepta a navegação, busca dados por AJAX e troca o conteúdo da tela. A página nunca recarrega.
Aspecto
Site multipágina (MPA)
SPA
Navegação
cada clique traz um HTML novo
o JS troca o conteúdo
Dados
já embutidos no HTML
buscados por AJAX (JSON)
Primeira carga
rápida
mais pesada (todo o JS)
Sensação
"site"
"aplicativo"
Nenhum dos dois é melhor sempre. Um blog ou um portal institucional vive bem como MPA e ainda ganha em SEO e em tempo de primeira carga. Um painel, um webmail, um sistema com muita interação ganha como SPA.
Frameworks como Vue e React automatizam a SPA (você verá isso no Nível 3), mas o núcleo cabe em uma função. Duas peças:
Telas: seções no HTML, apenas uma visível por vez.
Roteador: uma função que lê a URL, decide qual tela mostrar e dispara o carregamento de dados daquela tela.
HTML
<nav><ahref="#/inicio">Início</a><ahref="#/cardapio">Cardápio</a><ahref="#/contato">Contato</a></nav><main><sectiondata-rota="/inicio">Conteúdo da tela inicial</section><sectiondata-rota="/cardapio"hidden>Conteúdo do cardápio</section><sectiondata-rota="/contato"hidden>Conteúdo do contato</section></main>
JavaScript
functionnavegar(){constrota=location.hash.slice(1)||"/inicio";document.querySelectorAll("[data-rota]").forEach((tela)=>{tela.hidden=tela.dataset.rota!==rota;});}window.addEventListener("hashchange",navegar);// o usuário navegounavegar();// primeira carga: respeita a URL atual
O location.hash de http://site.com/#/cardapio é a string "#/cardapio"; o .slice(1) remove o # e sobra "/cardapio".
Existe outra forma de fazer roteamento sem recarga, com history.pushState(), que produz URLs limpas (/cardapio em vez de /#/cardapio). Ela é a usada em produção pelos frameworks — mas exige uma configuração no servidor: qualquer caminho precisa devolver o mesmo index.html, senão recarregar a página em /cardapio resulta em 404.
O GitHub Pages, onde o Café Cerrado está publicado, não permite essa configuração. Por isso usamos o hash: ele funciona em qualquer hospedagem estática, sem servidor nenhum, porque o navegador nunca envia a parte depois do # na requisição HTTP.
E o hash preserva de graça três coisas que uma SPA malfeita quebra:
Voltar e avançar no navegador funcionam, porque mudar o hash cria uma entrada no histórico.
Favoritar e compartilhar um link direto para uma tela funciona.
Recarregar com F5 mantém você na mesma tela.
6.4 O que a SPA quebra na acessibilidade (e como consertar)¶
Quando o navegador carrega uma página nova, ele faz três coisas automaticamente que uma SPA precisa refazer à mão:
Anuncia o título da página. Atualize document.title a cada troca de tela.
Move o foco para o topo do documento. Sem isso, quem navega por teclado continua com o foco no link clicado e o leitor de tela não anuncia nada. Solução: dar tabindex="-1" ao título da tela e chamar .focus() nele.
Sinaliza onde você está. No menu, marque o link ativo com aria-current="page" — o mesmo atributo da Aula 03, agora atualizado por JavaScript.
📌 Vale gravar
Três perguntas recorrentes: (1) por que fetch não rejeita em um 404; (2) qual a diferença entre JSON.parse e response.json() — o segundo lê o corpo e faz o parse, devolvendo uma Promise; (3) por que a navegação por hash preserva o histórico do navegador enquanto trocar classes CSS "na mão" não preserva.
Vamos transformar o site em uma aplicação de página única, com dados vindos de arquivo JSON e de APIs reais. O repositório termina a aula com esta estrutura:
Os arquivos cardapio.html, contato.html e js/dados.js deixam de existir: o conteúdo deles migra para o index.html e para o data/. Apague-os ao final — o histórico do Git guarda tudo, nada se perde.
[{"id":1,"nome":"Espresso do Cerrado","categoria":"cafes","preco":6,"descricao":"Grãos de Alto Paraíso, torra média, corpo encorpado e final achocolatado.","imagem":"img/espresso.jpg"},{"id":2,"nome":"Coado da Casa","categoria":"cafes","preco":8.5,"descricao":"Duzentos mililitros em coador de papel, moagem média feita na hora do pedido.","imagem":"img/coado.jpg"},{"id":3,"nome":"Cappuccino Sinop","categoria":"cafes","preco":12,"descricao":"Espresso duplo, leite vaporizado e canela do Cerrado por cima.","imagem":"img/cappuccino.jpg"},{"id":4,"nome":"Latte de Baunilha","categoria":"cafes","preco":14,"descricao":"Espresso, leite vaporizado e calda de baunilha feita na casa.","imagem":"img/latte.jpg"},{"id":5,"nome":"Cold Brew da Chapada","categoria":"geladas","preco":15,"descricao":"Extração a frio por dezoito horas, servida com gelo e rodela de laranja.","imagem":"img/cold-brew.jpg"},{"id":6,"nome":"Frappê de Café","categoria":"geladas","preco":16,"descricao":"Espresso batido com gelo, leite e chantili. Também sai sem lactose.","imagem":"img/frappe.jpg"},{"id":7,"nome":"Pão de Queijo Mineiro","categoria":"salgados","preco":7,"descricao":"Porção com quatro unidades de polvilho azedo com queijo canastra.","imagem":"img/pao-de-queijo.jpg"},{"id":8,"nome":"Torta de Frango","categoria":"salgados","preco":13,"descricao":"Fatia generosa com massa amanteigada e recheio de frango desfiado.","imagem":"img/torta-de-frango.jpg"},{"id":9,"nome":"Bolo de Milho Verde","categoria":"doces","preco":9.5,"descricao":"Fatia de bolo cremoso feito com milho da feira do produtor.","imagem":"img/bolo-de-milho.jpg"},{"id":10,"nome":"Brownie de Castanha","categoria":"doces","preco":11,"descricao":"Chocolate meio amargo com castanha-do-pará. Sem glúten.","imagem":"img/brownie.jpg"}]
Salve e abra http://127.0.0.1:5500/data/produtos.json no navegador. Se aparecer o JSON formatado, o caminho está certo; se aparecer 404, confira o nome da pasta antes de seguir.
Passo 2 — js/api.js: todo acesso a dados em um lugar¶
JavaScript
// cafe-cerrado/js/api.js// Camada de acesso a dados do Café Cerrado.// Nenhuma outra parte do projeto chama fetch diretamente.constBASE_TESTE="https://jsonplaceholder.typicode.com";constBASE_CEP="https://viacep.com.br/ws";/** * Faz a requisição, verifica o status e devolve o corpo já convertido de JSON. * Toda chamada do projeto passa por aqui. */exportasyncfunctionpegarJson(url,opcoes={}){constresposta=awaitfetch(url,opcoes);if(!resposta.ok){thrownewError(`Erro HTTP ${resposta.status} (${resposta.statusText}) em ${url}`);}returnresposta.json();}exportfunctionbuscarProdutos(){returnpegarJson("data/produtos.json");}exportfunctionbuscarCategorias(){returnpegarJson("data/categorias.json");}/** Depoimentos de clientes — por enquanto, comentários falsos da JSONPlaceholder. */exportfunctionbuscarDepoimentos(){returnpegarJson(`${BASE_TESTE}/comments?postId=1&_limit=4`);}/** Envia a mensagem do formulário de contato. A JSONPlaceholder responde 201 com um id. */exportfunctionenviarMensagem(mensagem){returnpegarJson(`${BASE_TESTE}/posts`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(mensagem),});}/** Consulta o endereço de um CEP brasileiro no ViaCEP. */exportasyncfunctionbuscarEnderecoPorCep(cep){constdigitos=cep.replace(/\D/g,"");if(digitos.length!==8){thrownewError("O CEP precisa ter 8 dígitos.");}constendereco=awaitpegarJson(`${BASE_CEP}/${digitos}/json/`);// O ViaCEP responde 200 OK com { "erro": "true" } quando o CEP não existe.if(endereco.erro){thrownewError("CEP não encontrado.");}returnendereco;}
O <head> continua exatamente como na Aula 06 (Bootstrap 5.3 por CDN com integrity, css/estilo.css, meta viewport). O que muda é o menu, o <main> e a linha do <script>.
O menu passa a apontar para rotas com #:
HTML
<!-- cafe-cerrado/index.html — navegação --><aclass="pular-para-conteudo"href="#conteudo">Pular para o conteúdo</a><header><navclass="navbar navbar-expand-md navbar-light"aria-label="Navegação principal"><divclass="container"><aclass="navbar-brand"href="#/inicio">Café Cerrado</a><ulclass="navbar-nav flex-row gap-3"><liclass="nav-item"><aclass="nav-link"href="#/inicio"data-link="/inicio">Início</a></li><liclass="nav-item"><aclass="nav-link"href="#/cardapio"data-link="/cardapio">Cardápio</a></li><liclass="nav-item"><aclass="nav-link"href="#/contato"data-link="/contato">Contato</a></li></ul></div></nav></header>
O <main> guarda as três telas. Só uma fica visível por vez — o roteador cuida disso com o atributo hidden:
HTML
<!-- cafe-cerrado/index.html — as três telas --><mainid="conteudo"class="container my-4"><sectionclass="tela"data-rota="/inicio"><h2tabindex="-1">Café Cerrado</h2><p>Cafeteria de Sinop/MT. Grãos do cerrado, torra da semana, sem pressa.</p><h3>O que dizem nossos clientes</h3><pid="status-depoimentos"class="status"role="status"aria-live="polite"></p><ulid="depoimentos"class="list-unstyled"aria-busy="false"></ul></section><sectionclass="tela"data-rota="/cardapio"hidden><h2tabindex="-1">Cardápio</h2><divclass="filtros row g-2 align-items-end mb-3"><divclass="col-12 col-md-5"><labelclass="form-label"for="busca">Buscar</label><inputclass="form-control"type="search"id="busca"placeholder="café, açaí, pão…"></div><divclass="col-6 col-md-4"><labelclass="form-label"for="filtro-categoria">Categoria</label><selectclass="form-select"id="filtro-categoria"><optionvalue="">Todas as categorias</option></select></div><divclass="col-6 col-md-3"><labelclass="form-label"for="ordenacao">Ordenar por</label><selectclass="form-select"id="ordenacao"><optionvalue="nome">Nome (A–Z)</option><optionvalue="preco-asc">Menor preço</option><optionvalue="preco-desc">Maior preço</option></select></div></div><pid="status-cardapio"class="status"role="status"aria-live="polite"></p><buttontype="button"id="tentar-de-novo"class="btn btn-outline-dark mb-3"hidden>Tentar de novo</button><divid="cards"class="row g-3"aria-busy="false"></div><pid="resumo"class="resumo"></p></section><sectionclass="tela"data-rota="/contato"hidden><h2tabindex="-1">Fale com a gente</h2><formid="form-contato"class="row g-3"novalidate><divclass="col-12 col-md-6"><labelclass="form-label"for="nome">Nome</label><inputclass="form-control"type="text"id="nome"name="nome"requiredminlength="3"></div><divclass="col-12 col-md-6"><labelclass="form-label"for="email">E-mail</label><inputclass="form-control"type="email"id="email"name="email"required></div><divclass="col-6 col-md-3"><labelclass="form-label"for="cep">CEP</label><inputclass="form-control"type="text"id="cep"name="cep"inputmode="numeric"maxlength="9"></div><divclass="col-6 col-md-4"><labelclass="form-label"for="cidade">Cidade/UF</label><inputclass="form-control"type="text"id="cidade"name="cidade"readonly></div><divclass="col-12"><labelclass="form-label"for="mensagem">Mensagem</label><textareaclass="form-control"id="mensagem"name="mensagem"rows="4"requiredminlength="10"></textarea></div><divclass="col-12"><buttonclass="btn btn-dark"type="submit"id="enviar">Enviar mensagem</button></div></form><pid="status-contato"class="status"role="status"aria-live="polite"></p></section></main>
E, antes do </body>, uma única linha de script — sem defer, porque módulos já são adiados:
HTML
<!-- cafe-cerrado/index.html — antes do </body> --><scripttype="module"src="js/app.js"></script>
// cafe-cerrado/js/roteador.js// Roteador por hash: lê a URL, mostra a tela certa e avisa quem quiser saber.constROTA_PADRAO="/inicio";constacoes=newMap();/** Registra o que deve acontecer ao entrar em uma rota (ex.: carregar dados). */exportfunctionregistrarRota(caminho,aoEntrar){acoes.set(caminho,aoEntrar);}functionlerRota(){constbruta=location.hash.slice(1);returnacoes.has(bruta)?bruta:ROTA_PADRAO;}functionmostrarTela(rota){document.querySelectorAll("[data-rota]").forEach((tela)=>{tela.hidden=tela.dataset.rota!==rota;});document.querySelectorAll("[data-link]").forEach((link)=>{constativo=link.dataset.link===rota;link.classList.toggle("active",ativo);if(ativo){link.setAttribute("aria-current","page");}else{link.removeAttribute("aria-current");}});}functionajustarTituloEFoco(rota,moverFoco){consttela=document.querySelector(`[data-rota="${rota}"]`);consttitulo=tela.querySelector("h2");document.title=`${titulo.textContent} — Café Cerrado`;if(moverFoco){titulo.focus();}}asyncfunctionnavegar(moverFoco){constrota=lerRota();mostrarTela(rota);ajustarTituloEFoco(rota,moverFoco);constaoEntrar=acoes.get(rota);if(aoEntrar){awaitaoEntrar();}}exportfunctioniniciarRoteador(){window.addEventListener("hashchange",()=>{navegar(true).catch((erro)=>console.error("Falha ao navegar:",erro));});// Primeira carga: respeita a URL que veio no link, mas não rouba o foco de ninguém.navegar(false).catch((erro)=>console.error("Falha ao navegar:",erro));}
Repare no .catch das duas chamadas de navegar. Sem ele, uma falha dentro da função de entrada da rota viraria um Uncaught (in promise) silencioso — exatamente o que a Aula 09 ensinou a evitar.
// cafe-cerrado/js/app.js// Café Cerrado — SPA com dados vindos de arquivo JSON e de APIs públicas.import{buscarProdutos,buscarCategorias,buscarDepoimentos,enviarMensagem,buscarEnderecoPorCep,}from"./api.js";import{registrarRota,iniciarRoteador}from"./roteador.js";constestado={produtos:[],categorias:[],termo:"",categoria:"",ordem:"nome",cardapioCarregado:false,depoimentosCarregados:false,};constelementos={cards:document.querySelector("#cards"),statusCardapio:document.querySelector("#status-cardapio"),resumo:document.querySelector("#resumo"),busca:document.querySelector("#busca"),filtroCategoria:document.querySelector("#filtro-categoria"),ordenacao:document.querySelector("#ordenacao"),tentarDeNovo:document.querySelector("#tentar-de-novo"),depoimentos:document.querySelector("#depoimentos"),statusDepoimentos:document.querySelector("#status-depoimentos"),formulario:document.querySelector("#form-contato"),statusContato:document.querySelector("#status-contato"),botaoEnviar:document.querySelector("#enviar"),cep:document.querySelector("#cep"),cidade:document.querySelector("#cidade"),};functionformatarPreco(valor){returnvalor.toLocaleString("pt-BR",{style:"currency",currency:"BRL"});}functionmostrarStatus(elemento,mensagem,modificador){elemento.textContent=mensagem;elemento.className=modificador?`status ${modificador}`:"status";}functionrenderizarEsqueleto(quantidade){elementos.cards.innerHTML="";elementos.cards.setAttribute("aria-busy","true");for(leti=0;i<quantidade;i+=1){constcoluna=document.createElement("div");coluna.className="col-12 col-sm-6 col-lg-4";constcaixa=document.createElement("div");caixa.className="esqueleto";caixa.setAttribute("aria-hidden","true");coluna.appendChild(caixa);elementos.cards.appendChild(coluna);}}functioncriarCard(produto){constcoluna=document.createElement("div");coluna.className="col-12 col-sm-6 col-lg-4";constcard=document.createElement("article");card.className="card h-100";constimagem=document.createElement("img");imagem.src=produto.imagem;imagem.alt=`Foto de ${produto.nome}`;imagem.className="card-img-top";imagem.loading="lazy";constcorpo=document.createElement("div");corpo.className="card-body";consttitulo=document.createElement("h3");titulo.className="card-title h5";titulo.textContent=produto.nome;constdescricao=document.createElement("p");descricao.className="card-text";descricao.textContent=produto.descricao;constpreco=document.createElement("p");preco.className="preco fw-bold";preco.textContent=formatarPreco(produto.preco);corpo.append(titulo,descricao,preco);card.append(imagem,corpo);coluna.appendChild(card);returncoluna;}functionrenderizarCards(lista){elementos.cards.innerHTML="";lista.forEach((produto)=>elementos.cards.appendChild(criarCard(produto)));elementos.cards.setAttribute("aria-busy","false");}functionatualizarResumo(lista){if(lista.length===0){elementos.resumo.textContent="";return;}consttotal=lista.reduce((acumulado,produto)=>acumulado+produto.preco,0);elementos.resumo.textContent=`${lista.length} item(ns) — soma dos preços: ${formatarPreco(total)}`;}functionpreencherFiltroCategorias(categorias){elementos.filtroCategoria.length=1;// mantém só a opção "Todas as categorias"categorias.forEach((categoria)=>{constopcao=document.createElement("option");opcao.value=categoria.id;opcao.textContent=categoria.nome;elementos.filtroCategoria.appendChild(opcao);});}functionordenar(lista,criterio){constcopia=[...lista];if(criterio==="preco-asc"){returncopia.sort((a,b)=>a.preco-b.preco);}if(criterio==="preco-desc"){returncopia.sort((a,b)=>b.preco-a.preco);}returncopia.sort((a,b)=>a.nome.localeCompare(b.nome,"pt-BR"));}functionaplicarFiltros(){consttermo=estado.termo.trim().toLowerCase();constfiltrados=estado.produtos.filter((produto)=>produto.nome.toLowerCase().includes(termo)).filter((produto)=>estado.categoria===""||produto.categoria===estado.categoria);constlista=ordenar(filtrados,estado.ordem);renderizarCards(lista);atualizarResumo(lista);if(lista.length===0){mostrarStatus(elementos.statusCardapio,"Nenhum item combina com esses filtros.","status--vazio");}else{mostrarStatus(elementos.statusCardapio,"");}}asyncfunctioncarregarCardapio(){if(estado.cardapioCarregado){return;// já buscamos uma vez: não repete a requisição}elementos.tentarDeNovo.hidden=true;mostrarStatus(elementos.statusCardapio,"Carregando o cardápio…");renderizarEsqueleto(6);try{const[produtos,categorias]=awaitPromise.all([buscarProdutos(),buscarCategorias()]);estado.produtos=produtos;estado.categorias=categorias;estado.cardapioCarregado=true;preencherFiltroCategorias(categorias);aplicarFiltros();}catch(erro){console.error(erro);elementos.cards.innerHTML="";elementos.cards.setAttribute("aria-busy","false");elementos.resumo.textContent="";mostrarStatus(elementos.statusCardapio,"Não foi possível carregar o cardápio.","status--erro");elementos.tentarDeNovo.hidden=false;elementos.tentarDeNovo.focus();}}asyncfunctioncarregarDepoimentos(){if(estado.depoimentosCarregados){return;}mostrarStatus(elementos.statusDepoimentos,"Carregando depoimentos…");elementos.depoimentos.setAttribute("aria-busy","true");try{constcomentarios=awaitbuscarDepoimentos();elementos.depoimentos.innerHTML="";comentarios.forEach((comentario)=>{constitem=document.createElement("li");item.className="depoimento";consttexto=document.createElement("p");texto.textContent=`“${comentario.body}”`;constautor=document.createElement("p");autor.className="autor";autor.textContent=comentario.name;item.append(texto,autor);elementos.depoimentos.appendChild(item);});estado.depoimentosCarregados=true;mostrarStatus(elementos.statusDepoimentos,`${comentarios.length} depoimentos carregados.`);}catch(erro){console.error(erro);mostrarStatus(elementos.statusDepoimentos,"Não foi possível carregar os depoimentos.","status--erro");}finally{elementos.depoimentos.setAttribute("aria-busy","false");}}asyncfunctionpreencherCidadePeloCep(){constvalor=elementos.cep.value.trim();if(valor===""){elementos.cidade.value="";return;}try{constendereco=awaitbuscarEnderecoPorCep(valor);elementos.cidade.value=`${endereco.localidade}/${endereco.uf}`;mostrarStatus(elementos.statusContato,"");}catch(erro){elementos.cidade.value="";mostrarStatus(elementos.statusContato,erro.message,"status--erro");}}asyncfunctionenviarFormulario(evento){evento.preventDefault();if(!elementos.formulario.checkValidity()){elementos.formulario.reportValidity();mostrarStatus(elementos.statusContato,"Confira os campos destacados.","status--erro");return;}constdados=newFormData(elementos.formulario);constmensagem={title:`Contato de ${dados.get("nome")}`,body:dados.get("mensagem"),email:dados.get("email"),userId:1,};elementos.botaoEnviar.disabled=true;mostrarStatus(elementos.statusContato,"Enviando…");try{constcriado=awaitenviarMensagem(mensagem);mostrarStatus(elementos.statusContato,`Mensagem enviada! Protocolo ${criado.id}.`);elementos.formulario.reset();elementos.cidade.value="";}catch(erro){console.error(erro);mostrarStatus(elementos.statusContato,"Não foi possível enviar. Tente novamente.","status--erro");}finally{elementos.botaoEnviar.disabled=false;}}functionligarEventos(){elementos.busca.addEventListener("input",(evento)=>{estado.termo=evento.target.value;aplicarFiltros();});elementos.filtroCategoria.addEventListener("change",(evento)=>{estado.categoria=evento.target.value;aplicarFiltros();});elementos.ordenacao.addEventListener("change",(evento)=>{estado.ordem=evento.target.value;aplicarFiltros();});elementos.tentarDeNovo.addEventListener("click",()=>{carregarCardapio().catch((erro)=>console.error(erro));});elementos.cep.addEventListener("change",()=>{preencherCidadePeloCep().catch((erro)=>console.error(erro));});elementos.formulario.addEventListener("submit",(evento)=>{enviarFormulario(evento).catch((erro)=>console.error(erro));});}registrarRota("/inicio",carregarDepoimentos);registrarRota("/cardapio",carregarCardapio);registrarRota("/contato",async()=>{});ligarEventos();iniciarRoteador();
Abra index.html pelo Live Server. A URL deve virar http://127.0.0.1:5500/index.html e, logo em seguida, mostrar a tela de início com os depoimentos carregando.
Clique em Cardápio. A URL vira http://127.0.0.1:5500/index.html#/cardapio e a página não recarrega — confira que o indicador de carregamento da aba do navegador não pisca.
Aperte F5 nessa URL. Você continua no cardápio: o link direto funciona.
Clique em Contato, depois no botão voltar do navegador. Você volta ao cardápio, e o menu marca o link certo.
Na aba Network, filtre por Fetch/XHR e navegue entre as telas. Você deve ver produtos.json, categorias.json e comments?postId=1&_limit=4 — cada um uma única vez, graças aos sinalizadores cardapioCarregado e depoimentosCarregados.
No formulário, digite o CEP 78550000 e saia do campo. O campo Cidade/UF deve preencher com Sinop/MT. Agora digite 00000000: o status mostra "CEP não encontrado."
Preencha nome, e-mail e mensagem e envie. Deve aparecer "Mensagem enviada! Protocolo 101." — a JSONPlaceholder sempre devolve o id 101, porque não grava nada de verdade.
Em Network, clique na requisição posts e veja a aba Payload: lá está o JSON que o seu JSON.stringify produziu. Na aba Response, o que o servidor devolveu.
Renomeie data/produtos.json para data/produtos-x.json e recarregue no cardápio. Deve aparecer a mensagem de erro e o botão "Tentar de novo" — e no console, Erro HTTP 404 (Not Found) em data/produtos.json. Volte o nome depois.
Resultado esperado: um único HTML, três telas navegáveis com histórico funcionando, dados vindos de arquivo e de duas APIs reais, formulário enviando por POST com feedback, e nenhum erro não tratado no console.
A Promise do fetch rejeitou? Justifique em uma linha.
A3. Qual a diferença entre JSON.parse(texto) e resposta.json()? As duas devolvem a mesma coisa? Responda citando o tipo de retorno de cada uma.
A4. Complete o objeto de opções para que o fetch abaixo envie um novo produto como JSON:
JavaScript
constnovo={nome:"Chá de hibisco",preco:6.5};constresposta=awaitfetch("https://jsonplaceholder.typicode.com/posts",{method:"POST",});
A5. A URL do navegador é http://127.0.0.1:5500/index.html#/contato. Qual o valor de location.hash? E o de location.hash.slice(1)? Se o usuário apagar o #/contato da barra de endereços e apertar Enter, qual rota o roteador escolhe e por quê?
A6. Em carregarCardapio() existe a guarda if (estado.cardapioCarregado) return;. Descreva o que passa a acontecer na aba Network se alguém apagar essa linha e o usuário alternar cinco vezes entre Início e Cardápio.
B1. Acrescente ao js/api.js a função buscarProdutoPorId(id), que busca data/produtos.json e devolve apenas o produto com aquele id, lançando Error com mensagem clara se não existir. Teste no console do navegador com um id válido e um inválido.
Resultado esperado: buscarProdutoPorId(3) devolve o objeto do Cappuccino Sinop; buscarProdutoPorId(99) cai no catch com a mensagem "Produto 99 não encontrado", e o console não mostra Uncaught (in promise).
Dica
Reaproveite buscarProdutos() em vez de repetir o fetch. Depois é só um find — e um throw quando o resultado for undefined.
B2. Adicione uma quarta tela ao Café Cerrado: #/sobre, com a história da cafeteria e um mapa em texto do endereço. Registre a rota, acrescente o link no menu e confirme que voltar/avançar continuam funcionando.
Resultado esperado: quatro links no menu, quatro seções com data-rota, document.title mudando em cada uma e aria-current="page" sempre no link correto.
Dica
A rota só aparece se você registrá-la: registrarRota("/sobre", async () => {}). Sem isso, lerRota() não a reconhece e cai na rota padrão.
B3. Troque a fonte dos depoimentos: em vez de comments, use https://jsonplaceholder.typicode.com/users e monte uma lista de "parceiros" com nome, cidade (address.city) e site (website) de cada usuário.
Resultado esperado: dez itens na lista, cada um com nome, cidade e site; o site é um link clicável que abre em nova aba com rel="noopener".
Dica
Inspecione um objeto de users no console antes de escrever o código: console.log(usuarios[0]). A cidade está aninhada em address, então o acesso é usuario.address.city.
B4. Trate a falta de internet. Antes de qualquer fetch, verifique navigator.onLine; se estiver false, mostre "Você está sem conexão" em vez de deixar o fetch falhar. Escute também os eventos online e offline do window para atualizar a mensagem sozinho.
Resultado esperado: com o modo avião ligado (ou o throttling "Offline" da aba Network), a mensagem aparece imediatamente; ao voltar a conexão, a mensagem some e o cardápio carrega sem recarregar a página.
Dica
navigator.onLine é confiável para dizer "não há rede nenhuma", mas não garante que a internet funcione — por isso ele complementa o try/catch, não o substitui.
B5. Persista os filtros na URL: ao digitar na busca ou trocar a categoria, atualize o hash para #/cardapio?busca=cafe&categoria=doces; ao carregar a página com esse hash, aplique os filtros automaticamente.
Resultado esperado: copiar a URL e abrir em outra aba reproduz exatamente a mesma tela filtrada.
Dica
Separe o hash em duas partes com split("?"): a primeira é a rota, a segunda alimenta new URLSearchParams(...). Para escrever sem criar entrada nova no histórico a cada tecla, use history.replaceState(null, "", novoHash).
C1. Implemente a rota de detalhe do produto: #/produto/3 mostra uma tela com a foto grande, o nome, a descrição, o preço e um botão "Voltar ao cardápio". Cada card do cardápio vira um link para a rota do seu produto. Se o id não existir, a tela mostra "Produto não encontrado" com link para o cardápio — sem quebrar a aplicação.
Resultado esperado: clicar em um card leva ao detalhe sem recarga; o botão voltar do navegador retorna ao cardápio com os filtros como estavam; abrir #/produto/99 direto na barra de endereços mostra a mensagem de não encontrado; abrir #/produto/3 direto funciona.
Dica
O roteador atual compara a rota inteira com as chaves do Map. Para rotas com parâmetro, quebre o caminho: const [, secao, parametro] = rota.split("/") transforma "/produto/3" em secao = "produto" e parametro = "3". Registre a ação pela seção e passe o parâmetro para a função de entrada. Lembre-se de que o parâmetro chega sempre como string: compare com Number(parametro) ou converta o id antes.
Um colega "simplificou" o pegarJson do projeto e agora a tela do cardápio mostra "Carregando…" para sempre quando o arquivo não existe — em vez da mensagem de erro. Pior: o console mostra um SyntaxError falando de <!DOCTYPE, que não parece ter nada a ver com o problema. Este é o trecho alterado:
JavaScript
// cafe-cerrado/js/api.js — versão com o bug plantadoexportasyncfunctionpegarJson(url,opcoes={}){constresposta=awaitfetch(url,opcoes);returnresposta.json();}
Reproduza o erro renomeando data/produtos.json, leia a mensagem inteira, explique a ligação entre o <!DOCTYPE e o arquivo que sumiu — e conserte.
Critérios de pronto
Um comentário de três linhas no api.js explica por que o fetchnão rejeitou diante de um 404 e de onde veio o <!DOCTYPE da mensagem.
Com o arquivo renomeado, a tela mostra a mensagem de erro amigável e o botão "Tentar de novo" em vez de ficar carregando.
A mensagem lançada inclui o status numérico e a URL que falhou, para facilitar a depuração.
Um print da aba Network mostrando a linha vermelha do 404 está no README.md, ao lado de uma frase explicando o que a coluna "Status" informa.
Pistas
Releia a seção 3.2: a Promise do fetch só rejeita quando a requisição não acontece.
Peça resposta.text() em vez de resposta.json() e imprima o resultado: você vai ver a página de erro do servidor, com na primeira linha.
O statusText ("Not Found") deixa a mensagem mais legível do que só o número.
Depois de consertar, teste também o caminho feliz — é fácil deixar um throw disparando quando não deveria.
Cada vez que alguém entra na tela do cardápio, você busca produtos.json de novo. Hoje isso é rápido porque o arquivo é seu e pequeno. Amanhã será uma API na internet, e a mesma resposta viajará dezenas de vezes por sessão. Construa um cache com validade: a primeira chamada busca na rede, as seguintes respondem da memória — até o dado envelhecer.
Critérios de pronto
Uma função comCache(chave, buscar, validadeEmSegundos) guarda o resultado e o instante da busca, e só chama buscar() de novo depois que a validade expirar.
O cardápio e as categorias passam a usá-la com validade de 60 segundos; os depoimentos, de 5 minutos.
Uma medição no README.md compara, com performance.now(), o tempo da primeira chamada e o da segunda (dentro da validade), com a diferença em milissegundos.
Um teste documentado prova que, passada a validade, a requisição volta a aparecer na aba Network.
O cache sobrevive a um F5 — e o README explica em duas linhas qual armazenamento você escolheu para isso e por quê.
Pistas
Um Map na memória basta para a primeira versão: a chave é a URL, o valor é { dados, salvoEm }.
Validade expirada é Date.now() - salvoEm > validadeEmSegundos * 1000.
Para sobreviver ao F5, sessionStorage guarda strings — o que significa JSON.stringify ao salvar e JSON.parse ao ler, exatamente como na seção 2.3.
Cuidado com duas chamadas simultâneas antes de a primeira responder: guardar a Promise no cache, e não só o resultado, resolve isso de graça.
O roteador da aula é um Map de caminhos fixos. Frameworks reais aceitam parâmetros (/produto/:id), rota coringa para 404, e proteção de rota (entrar em /admin só se estiver logado). Escreva o seu, genérico o suficiente para ser copiado para qualquer projeto — e prove que ele funciona.
Critérios de pronto
registrarRota("/produto/:id", aoEntrar) funciona: o aoEntrar recebe um objeto de parâmetros ({ id: "3" }) extraído da URL.
Existe uma rota coringa "*" que responde por qualquer caminho não registrado, exibindo uma tela 404 própria — e a URL não é alterada, para o usuário poder corrigi-la.
Um antesDeEntrar(rota, parametros) opcional pode bloquear a navegação devolvendo false ou redirecionar devolvendo outra rota; demonstre com uma tela fictícia de administração.
O roteador continua atualizando document.title, aria-current e o foco no título — e nada disso vive dentro do app.js.
Um arquivo docs/roteador.md documenta a API pública em uma tabela e traz um exemplo mínimo de uso em 15 linhas.
Pistas
Guarde cada rota registrada como um padrão em partes: "/produto/:id".split("/") dá ["", "produto", ":id"]. Comparar parte a parte com a URL atual, tratando as que começam com : como curinga, resolve a extração de parâmetros.
Ordem importa: teste as rotas exatas antes das paramétricas, e a coringa por último.
Para o antesDeEntrar assíncrono, await o resultado antes de decidir; retornar uma string significa redirecionar (location.hash = novaRota).
Cuidado com o laço infinito: se antesDeEntrar redirecionar para uma rota que também é bloqueada, a navegação nunca termina. Um contador de redirecionamentos por navegação evita o problema.
🔥
🔥 Boss — Pedido do Café Cerrado, do cardápio à confirmação¶
spafetchjsonprojeto
Este é o Boss da Unidade 2: um fluxo completo de pedido, em quatro telas, usando tudo o que você aprendeu desde a Aula 07 — DOM e eventos, map/filter/reduce, assíncrono com estados, fetch de arquivo e de API, POST, formulário validado e roteamento SPA. É também o melhor treino possível para o Marco 2: quem faz o Boss no projeto autoral já tem o marco praticamente pronto.
O fluxo: a pessoa navega o cardápio, adiciona itens ao carrinho, revisa o pedido, preenche a entrega com busca de CEP e confirma. O pedido é enviado por POST e a tela final mostra o número do protocolo.
Critérios de pronto
Quatro rotas funcionando com histórico e link direto: #/cardapio, #/carrinho, #/entrega e #/confirmacao.
O carrinho é um array no estado, com quantidade por item; adicionar o mesmo produto duas vezes soma a quantidade em vez de duplicar a linha.
O total é calculado com reduce e reexibido a cada mudança; o contador de itens aparece no menu, em todas as telas.
O carrinho sobrevive a um F5 (persistido com JSON.stringify / JSON.parse), e um botão "Esvaziar carrinho" pede confirmação antes de apagar.
Entrar em #/entrega com o carrinho vazio redireciona para #/cardapio com uma mensagem explicando o motivo.
A tela de entrega valida nome, telefone e endereço, e preenche cidade e rua pelo CEP (ViaCEP), tratando CEP inexistente.
Confirmar envia o pedido inteiro por POST para a JSONPlaceholder, com o botão desabilitado durante o envio, e mostra o protocolo devolvido na tela de confirmação.
Todas as quatro telas tratam carregando, erro e vazio, com role="status", aria-live e foco movido para o título a cada troca de tela.
O README.md traz um roteiro de teste numerado de 10 passos que qualquer pessoa consegue seguir para verificar o fluxo inteiro.
Pistas
Um único objeto estado com produtos, carrinho e entrega é mais fácil de depurar do que três variáveis soltas — e é exatamente a ideia que o Pinia formaliza no Nível 3.
Para somar quantidades, procure o item antes de inserir: const existente = carrinho.find((i) => i.id === produto.id).
Salve o carrinho a cada alteração em uma função só (salvarCarrinho()), chamada no fim de toda operação — assim você nunca esquece de persistir.
O redirecionamento com carrinho vazio cabe na função de entrada da rota: se carrinho.length === 0, location.hash = "#/cardapio" e mostre a mensagem.
Para o protocolo, a JSONPlaceholder devolve sempre id: 101 no POST. Guarde-o no estado antes de navegar para a confirmação, senão a tela abre sem dado nenhum.
Deixe o POST por último. Faça as telas e o carrinho funcionarem primeiro; envio é a parte mais curta do desafio.
Access to fetch at 'file:///…/data/produtos.json' from origin 'null' has been blocked by CORS policy
Página aberta com duplo clique, não por HTTP
Abrir pelo Live Server (http://127.0.0.1:5500)
Uncaught SyntaxError: Cannot use import statement outside a module
Faltou type="module" na tag <script>
Trocar <script src="js/app.js" defer> por <script type="module" src="js/app.js">
GET http://127.0.0.1:5500/js/api.js net::ERR_ABORTED 404
import sem ./ ou sem a extensão .js
Usar sempre o caminho relativo completo: from "./api.js"
SyntaxError: Unexpected token '<', "<!DOCTYPE "… is not valid JSON
.json() chamado sobre uma página de erro HTML
Testar resposta.ok antes de ler o corpo
SyntaxError: Expected double-quoted property name in JSON at position 42
Vírgula sobrando ou aspas simples no .json
Corrigir a sintaxe; validar o arquivo abrindo-o no navegador
TypeError: Failed to fetch
Sem internet, URL inválida ou CORS bloqueado pelo servidor de destino
Ler o console inteiro: a mensagem de CORS aparece logo abaixo
Access to fetch at 'https://exemplo.com/api' from origin 'http://127.0.0.1:5500' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
O servidor de destino não libera outras origens
Usar uma API que permita CORS; a definitiva é pedir pelo back-end (Unidade 3)
A página recarrega inteira ao enviar o formulário
Faltou evento.preventDefault() no submit
Chamar preventDefault() na primeira linha do handler
O menu troca a tela, mas voltar no navegador não funciona
A troca foi feita só por classe CSS, sem mexer no hash
Navegar sempre por href="#/rota"; deixar o hashchange decidir
Uncaught (in promise) TypeError: Cannot read properties of null (reading 'addEventListener')
O elemento não existe na tela atual (querySelector devolveu null)
Conferir o id no HTML; em SPA, o elemento precisa existir no index.html
O POST chega ao servidor sem corpo
Faltou JSON.stringify no body ou o cabeçalho Content-Type
Enviar body: JSON.stringify(objeto) com headers: { "Content-Type": "application/json" }
Mova os dados do seu domínio para data/<recurso>.json e substitua a fonte simulada da Aula 09 por um fetch real, mantendo o padrão carregando/sucesso/erro/vazio.
Reorganize o JavaScript em módulos ES: js/api.js (todo o acesso a dados), js/roteador.js (a navegação) e js/app.js (a aplicação), com <script type="module">.
Implemente a navegação SPA por hash com no mínimo três telas, com histórico funcionando, document.title atualizado, aria-current="page" no menu e foco movido para o título a cada troca.
Consuma ao menos uma API pública de verdade (JSONPlaceholder, ViaCEP ou outra à sua escolha que permita CORS) em alguma tela.
Faça o formulário enviar por POST sem recarregar a página, com o botão desabilitado durante o envio e mensagem de sucesso ou de erro.
Confira o console: zero erros não tratados, zero Uncaught (in promise).
Atualize o README.md com o que o projeto faz, como rodar (Live Server) e quais APIs ele consome.
Critério de pronto: o projeto autoral roda inteiro a partir de um único index.html, os dados vêm de arquivos JSON e de pelo menos uma API, e a navegação entre as telas não recarrega o navegador em momento nenhum.
Guarde no seu repositório: commit + push. Esta atividade é também a base do Marco 2 — veja as instruções abaixo.
Leitura dirigida (se você tem acesso a uma biblioteca virtual pela sua instituição): QUEIRÓS & PORTELA, capítulos sobre AJAX, JSON e comunicação com o servidor, e a introdução ao back-end com Node.js — a Unidade 3 começa na próxima aula.
Escopo. A evolução do seu projeto autoral (não o Café Cerrado, que é o projeto de exemplo construído ao longo das aulas) para uma aplicação client-side dinâmica: validação de formulários, DOM e eventos, programação assíncrona e SPA com AJAX/JSON. É o resultado das Aulas 07 a 10 sobre a base do Marco 1.
Requisitos:
Validação de formulário com pelo menos quatro campos: validação nativa do HTML (required, type, minlength, pattern) somada a mensagens em JavaScript por campo, anunciadas em região com aria-live.
DOM e eventos: uma lista de itens do seu domínio renderizada dinamicamente a partir de dados (nada de cards escritos à mão no HTML), com busca por texto, um filtro e uma ordenação funcionando juntos.
Operações em vetores: map, filter e reduce usados de verdade — inclusive um resumo calculado (total, média, contagem) que se atualiza a cada filtragem.
Programação assíncrona: carregamento de dados com async/await e tratamento explícito dos quatro estados — carregando, sucesso, erro e vazio — com caminho de recuperação no erro.
AJAX e JSON: dados vindos de pelo menos um arquivo .json do próprio repositório e de uma API pública real, sempre com verificação de resposta.ok.
Envio por POST de um formulário, com Content-Type: application/json, botão desabilitado durante o envio e feedback de sucesso e de falha, sem recarregar a página.
SPA com no mínimo três telas em um único HTML, roteamento por hash, histórico do navegador funcionando, link direto para cada tela e aria-current="page" no menu.
Acessibilidade mantida da Unidade 1: skip link, foco visível, contraste, alt nas imagens, foco movido para o título a cada troca de tela.
Repositório organizado: data/, js/ em módulos ES, css/, README.md explicando o projeto, como rodar e quais APIs consome; commits com mensagens descritivas ao longo do desenvolvimento.
Checklist de qualidade
Validação de formulário completa: nativa + JS, com mensagens acessíveis por campo.
DOM e eventos: render dinâmico, busca, filtro e ordenação funcionando juntos, não isolados.
Programação assíncrona: os quatro estados (carregando, sucesso, erro, vazio) tratados de verdade, com caminho de recuperação no erro — não só o caminho feliz.
AJAX e JSON: fetch de arquivo e de API, sempre checando resposta.ok, com POST funcionando de ponta a ponta.
SPA: roteamento por hash, histórico do navegador, link direto para cada tela e acessibilidade mantida na troca de tela.
Organização do repositório: README.md completo e histórico de commits que mostra o projeto evoluindo, não um único commit final.
Um projeto "pela metade" costuma ter um dos itens acima funcionando só no caminho feliz (sem tratar erro, sem lidar com lista vazia) — teste sempre os casos de borda antes de considerar algo pronto.
Sobre IA: use como apoio — explicar um erro, sugerir uma correção pontual, revisar um trecho —, não como atalho para gerar o projeto sem entender o que ele faz. O teste real: se alguém apontar para um await ou um filter do seu código e perguntar por que está ali, você precisa saber responder.
Abra a aba Network do DevTools ao alternar entre telas: nenhuma requisição repetida sem necessidade.
Force os quatro estados manualmente (desligue a rede, aponte para uma URL inexistente, filtre até a lista ficar vazia) e confirme que cada um tem uma tela própria, não uma tela em branco.
Navegue pelo histórico do navegador (voltar/avançar) dentro da SPA e confirme que cada tela tem link direto funcional.
Rode o teste de teclado e confira que o foco muda para o título a cada troca de tela.
Abra o repositório: README.md explica o projeto e como rodar, e o histórico de commits mostra evolução real ao longo das quatro aulas.
json.org — a especificação do JSON em português, em uma página com os diagramas de sintaxe.
QUEIRÓS, R.; PORTELA, F. Introdução ao Desenvolvimento Moderno para a Web. FCA, 2018 — AJAX, JSON e comunicação com o servidor.
LOUDON, K. Desenvolvimento de Grandes Aplicações Web. Novatec, 2019 — arquitetura de aplicações ricas no cliente.
ALVES, W. P. Projetos de Sistemas Web. Érica, 2015 — integração entre camadas de uma aplicação web.
PUREWAL, S. Aprendendo a Desenvolver Aplicações Web. Novatec, 2014 — AJAX e consumo de APIs.
Na próxima aula começa a Unidade 3, e o JavaScript atravessa a rede: com Node.js e Express você deixa de consumir a API dos outros e passa a construir a sua — num repositório novo, o cafe-cerrado-api, que a Aula 11 cria no primeiro passo. Aquele data/produtos.json que hoje é servido por acaso pelo Live Server vira o banco de dados de um servidor que você mesmo escreveu — e o fetch do front-end continuará exatamente igual, apontando para /api/produtos em vez de um arquivo.