Curso Full-Stack Completo – Clone do Airbnb com MERN
Neste curso full-stack, vamos explorar passo a passo como criar um clone do Airbnb com MERN Stack (MongoDB, Express, React e Node.js). Vamos abordar desde a configuração inicial do ambiente até a implementação de componentes React estilizados com Tailwind CSS.
Nosso HBNB terá as seguintes funcionalidades:
- Listagem de lugares para alugar.
- Exibição de imagens e descrição dos lugares.
- Sistema de login para check-in e checkout.
- Página de perfil com informações de reservas e acomodações criadas.
- Possibilidade de adicionar e excluir imagens, favoritar, e salvar alterações nas acomodações.
- Logout do usuário.
O que você aprenderá neste curso?
- O que é o MERN Stack e como ele funciona.
- Como configurar um ambiente de desenvolvimento eficiente.
- Como criar um projeto React utilizando Vite.
- Como estilizar a aplicação com Tailwind CSS.
- Como integrar a aplicação com um backend em Node.js e MongoDB.
- [aqui vou adicionar mais pontos quando tiver todos os episódios]
Aula 1 – Inicilializando o projeto e primeiros componentes
Nesta primeira parte da aula, nós vamos aprender a inicializar e configurar nosso projeto, além de criar os primeiros elementos essenciais para a configuração da nossa aplicação.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Configurando o Ambiente no VS Code
Antes de começar, é essencial configurar um ambiente de desenvolvimento eficiente. Certifique-se de ter instalado:
Se você ainda não sabe como configurar seu Visual Studio Code ou quer aprender a instalar o Node.js, consulte o nosso guia que mostra como fazer as duas coisas!
O que é o MERN Stack e por que usar ele?
O MERN Stack é um conjunto de tecnologias que permite o desenvolvimento de aplicações web completas, desde o frontend até o backend, utilizando apenas JavaScript. Ele é composto por:
- MongoDB: Um banco de dados NoSQL orientado a documentos, ideal para armazenar dados em JSON e oferecer escalabilidade.
- Express.js: Um framework minimalista para Node.js que facilita a criação de APIs e gerencia requisições do cliente.
- React: Uma biblioteca para construção de interfaces de usuário dinâmicas e interativas.
- Node.js: Um ambiente de execução JavaScript no lado do servidor, permitindo o desenvolvimento full-stack com a mesma linguagem.
O MERN Stack é muito utilizado por startups e grandes empresas devido à sua flexibilidade e alto desempenho. Ele permite construir aplicações modernas com um código unificado entre o frontend e o backend, reduzindo a complexidade do desenvolvimento.
Antes de prosseguir, se você não domina o básico de JavaScript, sugerimos a leitura deste post com tudo o que você precisa saber sobre JavaScript antes de aprender React.
Configuração do Ambiente de Desenvolvimento
Antes de começar, precisamos configurar nosso ambiente. Certifique-se de ter instalados:
- Node.js (necessário para o backend e gerenciamento de pacotes)
- MongoDB (para o armazenamento de dados)
- Visual Studio Code (IDE recomendada)
- Extensão Tailwind CSS IntelliSense (para facilitar a estilização com Tailwind)
Caso você não saiba como configurar seu VS Code ou precise ajuda para instalar o Node.js, dá uma olhada no nosso post onde ensinamos a fazer as duas coisas!
Criando o Projeto React com Vite
Vamos criar a interface do usuário com React e Vite.
Vite é uma ferramenta moderna que otimiza o desenvolvimento e melhora a performance da aplicação.
npm create vite@latest hashbnb --template react
cd hashbnb
npm installPara iniciar o servidor local e visualizar o projeto no navegador:
npm run devEstruturando o Frontend com React
Criando Componentes Essenciais
No React, dividimos a interface em componentes reutilizáveis. Vamos criar alguns dos principais componentes do nosso HashBNB.
main.jsx
Este é o ponto de entrada da aplicação:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./index.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<App />
</StrictMode>
);Aqui, usamos createRoot para renderizar a aplicação e StrictMode para alertar sobre possíveis problemas no código.
App.jsx
Define a estrutura principal da aplicação e as rotas:
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
import Header from "./components/Header";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Account from "./pages/Account/Account";
import axios from "axios";
import UserContextProvider from "./contexts/UserContext";
import Accomodation from "./pages/Accomodation";
axios.defaults.baseURL = import.meta.env.MODE === "development" ? "http://localhost:3000/api" : "/api";
axios.defaults.withCredentials = true;
function App() {
return (
<BrowserRouter>
<UserContextProvider>
<Header />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login/:id?" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/account/:subpage?" element={<Account />} />
<Route path="/account/:subpage/:action" element={<Account />} />
<Route path="/place/:id/:booking?" element={<Accomodation />} />
</Routes>
</UserContextProvider>
</BrowserRouter>
);
}
export default App;Explicação geral do código:
- React Router: Gerencia as rotas da aplicação.
- Axios: Facilita requisições HTTP para o backend.
- UserContextProvider: Permite compartilhamento de dados do usuário entre os componentes.
Criando o cabeçalho
O arquivo header.jsx é responsável por exibir a barra de navegação principal da aplicação. Ele inclui links para a página inicial, pesquisa de hospedagens e login/conta do usuário.
Explicação do header.jsx
O Header é um componente funcional React que utiliza o contexto do usuário (UserContext) para exibir informações personalizadas. Ele contém três seções principais:
- Logo e Nome da Aplicação: Exibe a logo da plataforma ao lado do nome "ashbnb", funcionando como um link para a página inicial.
- Pesquisa de Hospedagens: Um botão estilizado que permite iniciar uma busca por acomodações. Contém textos padrão como "Qualquer lugar", "Qualquer semana" e "Hóspedes?", além de um ícone de lupa para indicar a funcionalidade de busca.
- Menu do Usuário: Um botão interativo que exibe o nome do usuário logado (caso haja um). Se não houver login, o botão direciona para a página de autenticação. Inclui dois ícones representando um menu hambúrguer e o avatar do usuário.
O código fonte completo pode ser encontrado nos arquivos desta aula. Ele garante que a barra de navegação seja responsiva e exiba os links corretos dependendo do estado do usuário.
Mas vamos ver um pouco do que faz cada parte do código no arquivo header.jsx.
1. Importação de Dependências
No início do arquivo temos a linhas que importam as dependências necessárias.
import React from "react";
import { Link } from "react-router-dom";
import { useUserContext } from "../contexts/UserContext";O que faz cada dependência?
Reacté importado para permitir a criação do componente.Link(do React Router) é usado para navegação entre páginas sem recarregar a aplicação.useUserContexté um hook personalizado que permite acessar informações do usuário logado.
2. Definição do componente Header
const Header = () => {
const { user } = useUserContext();Headeré uma função que representa o componente de navegação.useUserContext()retorna um objetouser, que contém informações sobre o usuário logado.
3. Estrutura do Layout
O Header é envolvido em um <div> principal para manter a estilização da barra de navegação.
3.1. Logo e Nome da Aplicação
<Link to="/" className="flex items-center justify-end">
<img
src="..."
alt="Logo da Hashtag"
className="max-h-10"
/>
<p className="text-primary text-2xl font-bold">ashbnb</p>
</Link>- Exibe a logo e o nome da aplicação.
- Ao clicar, o usuário é redirecionado para a página inicial (
"/").
3.2. Botão de Pesquisa
<Link
to={"/"}
className="hidden cursor-pointer items-center justify-self-center rounded-full border-[1.5px] border-gray-300 py-2 pr-4 pl-6 shadow-md transition hover:shadow lg:order-none lg:flex"
>
<p className="border-r border-gray-300 pr-4">Qualquer lugar</p>
<p className="border-r border-gray-300 px-4">Qualquer semana</p>
<p className="px-4">Hóspedes?</p>
<div className="bg-primary aspect-square w-min rounded-full p-2 text-white">
<svg ...> </svg>
</div>
</Link>- Contém três opções pré-definidas de pesquisa: local, data e número de hóspedes.
- O ícone de lupa dentro de um botão circular indica a funcionalidade de busca.
- Usa classes Tailwind para estilização e responsividade.
3.3. Botão do Usuário (Login/Conta)
<Link
to={"/"}
className="hidden cursor-pointer items-center justify-self-center rounded-full border-[1.5px] border-gray-300 py-2 pr-4 pl-6 shadow-md transition hover:shadow lg:order-none lg:flex"
>
<p className="border-r border-gray-300 pr-4">Qualquer lugar</p>
<p className="border-r border-gray-300 px-4">Qualquer semana</p>
<p className="px-4">Hóspedes?</p>
<div className="bg-primary aspect-square w-min rounded-full p-2 text-white">
<svg ...> </svg>
</div>
</Link>
- Se o usuário estiver logado, o botão leva à página de conta (
"/account"). - Caso contrário, direciona para a página de login (
"/login").
Ícones do Botão
<svg ...> </svg> // Ícone de menu hambúrguer
<svg ...> </svg> // Ícone de avatar do usuário- O primeiro SVG representa um ícone de menu "hambúrguer" (três linhas horizontais).
- O segundo é um ícone de perfil.
Nome do Usuário
{user ? (
<p className="max-w-14 truncate sm:max-w-none">{user.name}</p>
) : (
<></>
)}- Se o usuário estiver logado, exibe o nome (
user.name). - Se não houver usuário, não exibe nada (
<></>).
4. Exportação do Componente
export default Header;- Permite que
Headerseja importado e usado em outras partes do projeto.
Como criar um Menu Hambúrguer Responsivo com React e Tailwind CSS
Passo 1: Estrutura HTML/JSX do Cabeçalho
O cabeçalho terá:
- Um logotipo (imagem + texto).
- Links de navegação (ex.: "Qualquer lugar", "Qualquer semana", "Hóspedes?").
- Um ícone de menu hambúrguer que aparece em telas menores.
- Um ícone de fechar para o menu aberto.
import { useState } from 'react';
import { Link } from 'react-router-dom';
function Header() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};
return (
<header className="bg-blue-900 text-white py-4 px-6 flex items-center justify-between">
<div className="container mx-auto flex items-center justify-between max-w-6xl">
{/* Logo */}
<Link to="/" className="flex items-center">
<img src="..." alt="Logo da Hashtag" className="max-h-10" />
<p className="text-primary text-2xl font-bold ml-2">ashbnb</p>
</Link>
{/* Menu Hambúrguer (visível em mobile) */}
<button
className={`lg:hidden ${isMenuOpen ? 'hidden' : 'block'}`}
onClick={toggleMenu}
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* Menu de Navegação */}
<nav className={`nav-links ${isMenuOpen ? 'menu-open' : ''}`}>
<button
className={`menu-close ${isMenuOpen ? 'block' : 'hidden'} fixed top-4 right-4 lg:hidden`}
onClick={toggleMenu}
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6 items-center">
<Link
to="/"
className="cursor-pointer rounded-full border-[1.5px] border-gray-300 py-2 px-6 shadow-md transition hover:shadow lg:flex"
>
<span className="border-r border-gray-300 pr-4">Qualquer lugar</span>
<span className="border-r border-gray-300 px-4">Qualquer semana</span>
<span className="px-4">Hóspedes?</span>
<div className="bg-primary aspect-square w-min rounded-full p-2 text-white">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
</Link>
</div>
</nav>
</div>
</header>
);
}
export default Header;Explicação:
- Logo: Mantém a estrutura original com imagem e texto.
- Botão Hambúrguer: Um SVG com lg:hidden (oculto em telas ≥1024px) que dispara toggleMenu ao clicar.
- Botão Fechar: Um SVG com fixed para posicionamento no canto superior direito, visível apenas quando o menu está aberto.
- Nav Links: Contém os links de navegação, que mudam de flex-row (desktop) para flex-col (mobile) com media queries.
Passo 2: Estilização com Tailwind CSS
Adicione estilos para o menu hambúrguer e a responsividade. Usando Tailwind, adaptamos as classes para a estilização (ex.: fundo azul, centralização, animação):
/* No arquivo CSS ou no <style> do componente */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Reset CSS */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Manrope', sans-serif;
background-color: #f7f7f7;
}
header {
background-color: #1e3a8a; /* Azul escuro */
color: white;
font-size: 1.125rem; /* 18px */
font-weight: 600; /* Semibold */
}
header a {
color: white;
text-decoration: none;
}
/* Estilo para SVG (hambúrguer e fechar) */
svg {
width: 32px;
height: 32px;
}
/* Estilo do menu de navegação */
.nav-links {
display: flex;
align-items: center;
}
/* Media Query para Mobile (≤767px) */
@media (max-width: 767px) {
.nav-links {
position: fixed;
inset: 0; /* top: 0, right: 0, bottom: 0, left: 0 */
background-color: #1e3a8a;
padding: 2.25rem; /* 36px */
flex-direction: column;
align-items: center;
justify-content: center;
transform: translateX(100%); /* Menu fora da tela à direita */
transition: transform 0.3s ease; /* Animação suave */
}
.nav-links.menu-open {
transform: translateX(0); /* Menu visível */
}
.menu-close {
display: block;
}
}
}Explicação:
- Reset CSS: Zera margens e define box-sizing: border-box.
- Header: Usa fundo azul escuro (#1e3a8a), fonte branca, tamanho 18px e peso semibold.
- SVG: Define tamanho de 32px para ícones de hambúrguer e fechar.
- Nav Links (Mobile):
- position: fixed e inset: 0 fazem o menu ocupar a tela inteira.
- transform: translateX(100%) esconde o menu à direita.
- transition: transform 0.3s ease adiciona uma animação suave.
- Quando .menu-open é adicionado, transform: translateX(0) traz o menu de volta.
- Botão Fechar: Posicionado com fixed a 15px do topo e da direita, visível apenas quando o menu está aberto.
Passo 3: Adicionando Interatividade com JavaScript
O JavaScript gerencia a abertura/fechamento do menu. No código JSX, usamos o estado isMenuOpen para controlar a classe menu-open.
// Já incluído no componente Header
const [isMenuOpen, setIsMenuOpen] = useState(false);
const toggleMenu = () => {
setIsMenuOpen(!isMenuOpen);
};- Evento de Clique: O botão hambúrguer adiciona a classe menu-open ao <nav> (via estado), mostrando o menu. O botão fechar remove a classe, escondendo o menu.
- Animação: A propriedade transition no CSS anima a transição do menu.
Resultado Final
- Desktop: O cabeçalho mostra o logotipo à esquerda e os links de navegação à direita, estilizados com bordas e sombras.
- Mobile: O botão hambúrguer aparece. Ao clicar, o menu ocupa a tela inteira, com links em coluna e um botão fechar no canto superior direito. A animação suave melhora a experiência do usuário.
- Breakpoint: 767px, comum para mobiles deitados, mas ajustável conforme o layout.
Dica: Para personalizar, adicione mais links ou estilize os SVGs com cores diferentes. Baixe o arquivo deste guia e pratique.
Criando um Rodapé Responsivo com React e Tailwind CSS
Neste post, vamos ensinar como criar um rodapé responsivo para uma página web usando React e Tailwind CSS, inspirado na transcrição fornecida. O rodapé será funcional em diferentes tamanhos de tela, com um layout que se adapta automaticamente, incluindo seções para o nome da empresa, links de navegação, redes sociais e informações de copyright. Vamos adaptar os conceitos da transcrição, que ensina a criar um rodapé responsivo com HTML e CSS, para um componente React moderno, utilizando o código do Header e o CSS associado como base para a estilização responsiva.
Esta seção é baseada no vídeo abaixo:
Por que um rodapé responsivo é importante?
Um rodapé responsivo garante que informações importantes, como links de navegação, redes sociais e copyright, sejam exibidas de forma clara e acessível em qualquer dispositivo, seja desktop, tablet ou celular. Ele organiza o conteúdo de maneira fluida, mantendo a identidade visual do site e melhorando a experiência do usuário.
- Benefícios:
- Facilita o acesso a links importantes em telas pequenas.
- Mantém a consistência visual em diferentes dispositivos.
- Reforça a identidade da marca com um design organizado.
Estrutura do Rodapé com React
O componente Footer será criado em React, inspirado na estrutura do rodapé da transcrição, que divide o conteúdo em duas linhas: uma com a marca e links, e outra com copyright e redes sociais. Usaremos o react-router-dom para links de navegação e um estado simples para interações futuras, se necessário.
import { Link } from 'react-router-dom';
function Footer() {
return (
<footer className="bg-blue-900 text-white py-8 px-6">
<div className="container mx-auto max-w-6xl grid grid-cols-4 gap-8 md:grid-cols-2 sm:grid-cols-1">
{/* Seção da Marca */}
<div className="footer-brand col-span-1 md:col-span-2 sm:col-span-1">
<Link to="/" className="flex items-center">
<img src="..." alt="Logo da Hashtag" className="max-h-10" />
<h2 className="text-2xl font-bold ml-2">ashbnb</h2>
</Link>
<p className="mt-2 text-sm">Sua plataforma de confiança para reservas incríveis.</p>
</div>
{/* Links de Navegação */}
<div className="footer-links">
<h3 className="text-lg font-semibold mb-2">Links Úteis</h3>
<ul className="list-none space-y-2">
<li><Link to="/sobre" className="hover:text-gray-300">Sobre Nós</Link></li>
<li><Link to="/contato" className="hover:text-gray-300">Contato</Link></li>
<li><Link to="/termos" className="hover:text-gray-300">Termos de Uso</Link></li>
</ul>
</div>
{/* Links de Suporte */}
<div className="footer-links">
<h3 className="text-lg font-semibold mb-2">Suporte</h3>
<ul className="list-none space-y-2">
<li><Link to="/faq" className="hover:text-gray-300">FAQ</Link></li>
<li><Link to="/ajuda" className="hover:text-gray-300">Central de Ajuda</Link></li>
<li><Link to="/politicas" className="hover:text-gray-300">Políticas</Link></li>
</ul>
</div>
{/* Redes Sociais e Copyright */}
<div className="footer-social col-span-1 md:col-span-2 sm:col-span-1 justify-self-start sm:justify-self-center">
<h3 className="text-lg font-semibold mb-2">Siga-nos</h3>
<div className="flex space-x-4">
<a href="https://twitter.com" target="_blank" rel="noopener noreferrer">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M23 3a10.9 10.9 0 01-3.14 1.53 4.48 4.48 0 00-7.86 3v1A10.66 10.66 0 013 4s-4 9 5 13a11.64 11.64 0 01-7 2c9 5 20 0 20-11.5a4.5 4.5 0 00-.08-.83A7.72 7.72 0 0023 3z" />
</svg>
</a>
<a href="https://facebook.com" target="_blank" rel="noopener noreferrer">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M18 2h-3a5 5 0 00-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 011-1h3z" />
</svg>
</a>
</div>
<p className="mt-4 text-sm">© 2025 ashbnb. Todos os direitos reservados.</p>
</div>
</div>
</footer>
);
}
export default Footer;Explicação do código:
- Marca: Inclui o logo e uma descrição curta, com um link para a página inicial, similar ao Header.
- Links de navegação: Duas colunas com links úteis e de suporte, organizados em listas não ordenadas (ul).
- Redes sociais e copyright: Ícones de redes sociais (Twitter e Facebook) e texto de copyright, posicionados na última coluna em desktops e centralizados em mobiles.
- Layout: Usa CSS Grid com grid-cols-4 para desktops, ajustando para grid-cols-2 em tablets e grid-cols-1 em celulares, inspirado na lógica de grid da transcrição.
Estilizando o Rodapé com Tailwind CSS
O CSS utiliza Tailwind para criar um layout responsivo, inspirado na transcrição, que usa CSS Grid e media queries para ajustar o rodapé em diferentes tamanhos de tela. Adaptamos o CSS do Header para o rodapé, incorporando breakpoints e estilização semelhante.
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Reset CSS */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Manrope', sans-serif;
background-color: #f7f7f7;
}
footer {
background-color: #1e3a8a; /* Azul escuro */
color: white;
font-size: 1.125rem; /* 18px */
font-weight: 600; /* Semibold */
}
footer a {
color: white;
text-decoration: none;
}
/* Estilo para SVG (ícones de redes sociais) */
svg {
width: 24px;
height: 24px;
}
/* Estilo do container do rodapé */
.container {
display: grid;
grid-template-columns: 3fr 1fr 1fr 1fr;
gap: 2rem;
align-items: start;
}
/* Estilo para a seção de marca */
.footer-brand h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
/* Estilo para links */
.footer-links h3 {
font-size: 1.125rem;
margin-bottom: 0.5rem;
}
.footer-links ul {
list-style: none;
}
.footer-links a:hover {
color: #d1d5db; /* Cinza claro para hover */
}
/* Estilo para redes sociais e copyright */
.footer-social {
justify-self: start;
}
/* Media Query para Tablet (≤1000px) */
@media (max-width: 1000px) {
.container {
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.footer-brand {
grid-column: 1 / 3;
}
.footer-social {
grid-column: 1 / 3;
justify-self: center;
}
}
/* Media Query para Mobile (≤480px) */
@media (max-width: 480px) {
footer {
padding: 1.5rem;
}
.container {
grid-template-columns: 1fr;
gap: 1rem;
}
.footer-brand,
.footer-links,
.footer-social {
grid-column: auto;
justify-self: center;
text-align: center;
}
}
}Explicação do CSS:
- Reset CSS: Zera margens e paddings para consistência, como no reset da transcrição.
- Grid Layout: Usa grid-template-columns: 3fr 1fr 1fr 1fr para criar quatro colunas, com a primeira (marca) ocupando mais espaço, similar às frações (fr) mencionadas na transcrição.
- Breakpoints:
- Tablet (≤1000px): Reduz para duas colunas (repeat(2, 1fr)), com a marca e redes sociais ocupando duas colunas.
- Mobile (≤480px): Uma única coluna (1fr), com todos os elementos centralizados e padding reduzido.
- Estilização: Usa a mesma paleta de cores do Header (#1e3a8a) para consistência visual.
Testando a Responsividade
Para verificar a responsividade, use as ferramentas de desenvolvedor do navegador (F12):
- Desktop (>1000px): Quatro colunas, com a marca à esquerda e links/redes sociais à direita.
- Tablet (≤1000px): Duas colunas, com a marca e redes sociais ocupando mais espaço.
- Mobile (≤480px): Uma coluna, com todos os elementos centralizados para facilitar a leitura.
Dica prática: No Chrome, pressione F12, clique no ícone de dispositivo (“Toggle Device Toolbar”) e ajuste a largura da tela para 1000px e 480px. Verifique se o layout se adapta corretamente e se os links permanecem acessíveis.
Como melhorar o rodapé?
Inspirado na transcrição, você pode incrementar o rodapé com:
- Mais seções de links: Adicione categorias como “Recursos” ou “Blog” no componente Footer.
- Ícones animados: Aplique transições suaves nos ícones de redes sociais (ex.: transition: color 0.3s ease no CSS).
- Formulário de newsletter: Inclua uma seção para inscrição em newsletters, usando um input e um botão no Footer.
Tabela 1: Breakpoints do Rodapé
| Tamanho da Tela | Comportamento | Estilização Principal |
|---|---|---|
| >1000px (Desktop) | 4 colunas (marca, links, links, redes sociais) | grid-template-columns: 3fr 1fr 1fr 1fr |
| ≤1000px (Tablet) | 2 colunas, marca e redes sociais ocupam mais espaço | grid-template-columns: repeat(2, 1fr) |
| ≤480px (Mobile) | 1 coluna, tudo centralizado | grid-template-columns: 1fr, text-align: center |
Conclusão
Criar um rodapé responsivo com React e Tailwind CSS é uma tarefa prática que eleva a qualidade de sua página web. Com o código fornecido, você tem um rodapé funcional, elegante e adaptável, inspirado na lógica de grid e breakpoints da transcrição. Teste a responsividade, adicione mais seções ou personalizações, e use as ferramentas de desenvolvedor para garantir um layout perfeito. Como na transcrição, o segredo está em dominar os breakpoints e organizar o conteúdo de forma clara e acessível.
Configurando o Backend com Node.js e Express
O backend será responsável por processar requisições e interagir com o banco de dados.
Instalando Dependências
npm init -y
npm install express mongoose cors dotenvCriando o Servidor Express
Crie um arquivo server.js:
import express from "express";
import mongoose from "mongoose";
import dotenv from "dotenv";
import cors from "cors";
dotenv.config();
const app = express();
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGO_URL, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
app.listen(3000, () => console.log("Servidor rodando na porta 3000"));Este código inicializa o Express, conecta ao MongoDB e define uma API pronta para expansão.
Encerramento da Aula 1
Nesta primeira aula, vimos como configurar um projeto MERN Stack do zero, estruturamos, criamos nossos primeiros componentes reutilizáveis e estilizamos a interface com Tailwind CSS.
Aula 2 - Estruturação do Frontend com React e Tailwind CSS
Nesta segunda aula, daremos continuidade ao desenvolvimento do HashBNB, um aplicativo semelhante ao Airbnb, utilizando as tecnologias MERN Stack.
Vamos explorar a estrutura do frontend, que inclui a configuração de rotas, criação de componentes e a estilização da interface com Tailwind CSS. Também abordaremos a importância do versionamento de código utilizando o Git e o GitHub.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
GitHub e versionamento de código
O que é versionamento de código?
O versionamento de código é uma prática essencial no desenvolvimento de software, permitindo que os desenvolvedores acompanhem alterações feitas no código ao longo do tempo. Isso facilita a colaboração entre equipes e a recuperação de versões anteriores em caso de erro. Para gerenciar nosso código, utilizaremos o Git, um sistema de controle de versão amplamente utilizado.
Criando repositório no GitHub
Para começar, precisamos criar um repositório no GitHub. Acesse sua conta do GitHub, clique em "New repository" e preencha as informações necessárias, como nome e descrição do repositório. Após criar o repositório, você terá um local para armazenar e compartilhar o código do seu projeto.
Instalando e configurando Git
Certifique-se de que o Git esteja instalado no seu sistema. Para verificar se está instalado, execute o comando git --version no terminal. Se não estiver instalado, você pode baixá-lo em git-scm.com.
Conectando os repositórios e primeiro commit
Com o repositório criado e o Git instalado, podemos conectar nosso projeto local ao repositório remoto. No terminal, navegue até o diretório do seu projeto e execute os seguintes comandos:
git init
git remote add origin <URL do repositório>
git add .
git commit -m "Initial commit"
Esses comandos inicializam um repositório Git, adicionam o repositório remoto e fazem o primeiro commit.
O que é uma branch?
Uma branch (ramo) é uma linha de desenvolvimento no Git que permite trabalhar em funcionalidades ou correções sem afetar o código principal. Podemos criar branches para novas funcionalidades e, uma vez testadas, integrá-las ao branch principal.
Fazendo um push para GitHub
Para enviar as alterações locais para o repositório remoto, utilize o comando:
git push origin mainIsso garante que todas as suas alterações sejam salvas no GitHub.
Ajustando as credenciais do GitHub
Caso você esteja usando autenticação por token ou SSH, pode ser necessário ajustar suas credenciais. Siga as instruções do GitHub para configurar suas credenciais adequadamente.
Criando componentes no React
A seguir, vamos criar os componentes do nosso aplicativo. Abaixo vamos explicar por partes o componente que mostra todos os itens, as casas e lugares para alugar, o arquivo se chama itens.jsx.
Ele define um componente React chamado Item, usado para exibir uma prévia de um lugar (provavelmente em uma aplicação tipo Airbnb ou de aluguel por temporada). Aqui vai um detalhamento:
1. Importações
import axios from "axios";
import React from "react";
import { Link } from "react-router-dom";
import Image from "./Image";
axios: biblioteca para fazer requisições HTTP (não é usada diretamente neste componente, talvez esteja reservada para uso posterior ou foi deixada por engano).React: necessário para usar JSX e componentes.Link: componente doreact-router-dompara navegar entre páginas sem recarregar a página inteira.Image: provavelmente um componente personalizado para exibir imagens (pode conter tratamento de erro, lazy loading, etc.).
2. Componente Item
const Item = ({ _id: id, description, price, photos, address }) => {- Este é um componente funcional que recebe props com as seguintes propriedades:
_id(renomeado paraid): identificador único do lugar.description: descrição do local.price: preço por noite.photos: array de URLs de imagens.address: endereço do local.
3. Verificação de fotos
if (!photos) return <></>;- Se
photosnão estiver definido (forundefined,nulloufalse), o componente não renderiza nada.
4. Renderização principal
return (
<Link to={"/place/" + id} className="flex flex-col gap-2">- O componente renderiza um link clicável que leva à rota
/place/{id}. - Usa classes do Tailwind CSS para estilização (
flex,gap-2, etc.).
5. Imagem
<Image
src={photos[0]}
alt="Imagem do lugar"
className="aspect-square overflow-hidden rounded-2xl object-cover"
/>- Mostra a primeira imagem do array
photos. - Usa classes Tailwind para deixar a imagem quadrada, com bordas arredondadas e preenchimento ajustado.
6. Endereço e descrição
<div>
<h2 className="truncate text-lg font-semibold">{address}</h2>
<p className="truncate text-gray-600 hover:text-ellipsis">
{description}
</p>
</div>- Exibe o endereço e a descrição.
truncatecorta o texto com reticências se for muito longo.
7. Preço
<p>
<span className="font-semibold">R$ {price}</span> por noite
</p>- Mostra o preço formatado em real com o texto "por noite".
8. Exportação
export default Item;- Permite que o componente seja importado e usado em outros arquivos.
Esse componente Item mostra um "card" de um lugar para alugar, com imagem, endereço, descrição e preço. Tudo isso dentro de um link clicável que leva à página de detalhes do lugar.
Estruturando e espaçando itens na Home
Vamos agora olhar para a página inicial, um componente um pouco mais simples, onde exibiremos as acomodações disponíveis. O código da página Home.jsx é o seguinte:
import React from "react";
import Item from "../components/Item";
const Home = () => {
return (
<section>
<div className="mx-auto grid max-w-7xl grid-cols-[repeat(auto-fit,minmax(225px,1fr))] gap-8 p-8">
<Item />
<Item />
<Item />
<Item />
<Item />
<Item />
<Item />
<Item />
</div>
</section>
);
};
export default Home;Explicação do Código
- Estrutura de Grade: Utiliza classes do Tailwind CSS para criar uma grade responsiva que se adapta a diferentes tamanhos de tela.
Item: Cada componenteItemrepresenta uma acomodação individual, como vimos anteriormente.
Criando layout responsivo com Tailwind CSS
O Tailwind CSS entra nesse código através das classes que estão nos elementos JSX. Ele é um framework de utilitários CSS que permite você estilizar diretamente no HTML/JSX com classes curtas e descritivas, sem escrever CSS separado.
Exemplo real no código:
<Link to={"/place/" + id} className="flex flex-col gap-2">Tailwind aqui:
flex: transforma o container em flexbox.flex-col: organiza os filhos em coluna vertical.gap-2: adiciona um espaço entre os elementos filhos (0.5rem por padrão).
Criando a página de Login
Este código define um componente funcional React chamado Login, que representa uma página de login para nossa aplicação.
Ele usa React Router (Link) para permitir a navegação sem recarregar a página e Tailwind CSS para estilização. Agora, vamos analisar em detalhes:
Estrutura Geral
import React from "react";
import { Link } from "react-router-dom";
const Login = () => {
return (
<section className="flex items-center min-h-screen">
<div className="mx-auto flex w-full max-w-96 flex-col items-center gap-4">
<h1 className="text-3xl font-bold">Faça seu login</h1>- Importações: React e
Linkdo React Router para navegação. - Estrutura principal:
section: Usa flexbox (flex items-center) e ocupa a tela inteira (min-h-screen).div: Centraliza o conteúdo (mx-auto), define uma largura máxima (max-w-96), e organiza os itens em coluna (flex-col gap-4).
- Título: Texto grande (
text-3xl font-bold).
Formulário
<form className="flex w-full flex-col gap-2">
<input
type="email"
className="w-full rounded-full border border-gray-300 px-4 py-2"
placeholder="Digite seu e-mail"
/>
<input
type="password"
className="w-full rounded-full border border-gray-300 px-4 py-2"
placeholder="Digite sua senha"
/>
<button className="bg-primary-400 w-full cursor-pointer rounded-full border border-gray-300 px-4 py-2 font-bold text-white">
Login
</button>
</form>- Formulário: Usa
flex-colpara empilhar os elementos. - Inputs (
emailepassword):w-full: Ocupa toda a largura disponível.rounded-full: Bordas arredondadas.border-gray-300: Borda cinza clara.
- Botão de Login:
bg-primary-400 text-white: Fundo colorido e texto branco.cursor-pointer: Muda o cursor ao passar por cima.
Texto de Registro e Exportação
<p>
Ainda não tem uma conta?{" "}
<Link to="/register" className="font-semibold underline">
Registre-se aqui!
</Link>
</p>
</div>
</section>
);
};
export default Login;
- Texto com Link:
Link to="/register": Permite navegação sem recarregar a página.font-semibold underline: Deixa o link semi-negrito e sublinhado.
- Exportação:
export default Login;para ser reutilizado em outros arquivos.
Resumo
- Usa React Router para navegação.
- Usa Tailwind CSS para estilizar o layout flexível e responsivo.
- Formulário simples com campos arredondados e botão estilizado.
Configurando rotas com React Router
Já mencionamos o uso do React Router para gerenciar as rotas. O componente App.jsx já estabelece as rotas para a página inicial e de login, permitindo uma navegação fluida na aplicação.
Criando links de navegação para Home e Login
O componente Header.jsx contém links de navegação, permitindo que os usuários voltem para a página inicial ou acessem a página de login de qualquer lugar da aplicação.
Estruturando e estilizando o formulário de login
O formulário de login foi estilizado utilizando classes do Tailwind CSS, garantindo que ele seja visualmente atraente e fácil de usar.
Salvando alterações no GitHub
Não se esqueça de salvar suas alterações no GitHub com os comandos que discutimos!
git add .
git commit -m "Add login and home components"
git push origin main
Encerramento da Aula 2
Concluímos mais uma etapa do desenvolvimento do HashBNB. Agora temos uma estrutura básica para o frontend, incluindo componentes, estilização e rotas. É essencial continuar praticando e aprimorando cada parte da aplicação.
Aula 3 – Construindo a Tela de Login e Configurando o Backend
Já preparamos nosso ambiente de desenvolvimento, inicializamos o projeto React com Vite, criamos os primeiros componentes essenciais como o Header, estruturamos a página inicial (Home) e desenvolvemos a base da tela de login (Login) para o nosso projeto HashBnB em MERN FullStack.
Na aula anterior, também aprendemos a versionar nosso código utilizando Git e GitHub.
Nesta terceira parte, vamos aprender a criar a funcionalidade de login propriamente dita, conectando o frontend que construímos com um backend completo, capaz de autenticar nossos usuários.
Preparados para dar vida à nossa aplicação?
Nesta aula, vamos:
✅ Conectar o frontend ao backend
✅ Implementar autenticação (login/registro)
✅ Proteger rotas privadas
✅ Gerenciar estado do usuário com Context API
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Ajustes Finais no Frontend: Dando um Toque de Design
Antes de focarmos no backend, fizemos um pequeno ajuste no frontend para garantir que a tela de login se alinhe visualmente com a interface do nosso Hash-bnb.
Especificamente, realizamos uma alteração na configuração do tema, no arquivo index.js, para aplicar a fonte correta ao nosso componente de login. Pequenos detalhes como esse fazem toda a diferença na experiência do usuário!
A Necessidade de uma API: O Cérebro da Autenticação
Com a interface da tela de login pronta, o próximo passo crucial é implementar a lógica por trás dela. É aqui que entra o nosso backend, responsável por receber as credenciais do usuário (e-mail e senha), verificar se elas são válidas e, em caso positivo, permitir o acesso à aplicação [03:18]. Para isso, precisamos construir uma API (Interface de Programação de Aplicações) que atuará como a ponte entre o frontend e o nosso banco de dados.
Inicializando o Backend com Node.js e Express
O primeiro passo para construir nossa API é configurar o ambiente do backend. Navegamos até a pasta backend (que provavelmente criamos na raiz do nosso projeto) e inicializamos um novo projeto Node.js com o seguinte comando:
npm init -yEste comando cria um arquivo package.json, que gerencia as dependências do nosso backend. Em seguida, instalamos as bibliotecas essenciais para construir nossa API:
npm install express mongoose cors dotenv- express: Um framework web minimalista e flexível para Node.js, que facilitará a criação de nossas rotas e o gerenciamento das requisições.
- mongoose: Uma biblioteca que fornece uma modelagem de objetos MongoDB elegante para o Node.js, simplificando a interação com o nosso banco de dados.
- cors: Um middleware do Express que permite o compartilhamento de recursos entre diferentes origens (nosso frontend rodando em um endereço e o backend em outro).
- dotenv: Um pacote que permite carregar variáveis de ambiente de um arquivo
.env, ideal para armazenar informações confidenciais como a URL do nosso banco de dados.
Criando o Servidor Express (backend/index.js)
Com as dependências instaladas, criamos o arquivo principal do nosso backend, provavelmente chamado index.js dentro da pasta backend. Este arquivo conterá a lógica para inicializar o servidor Express, configurar os middlewares e definir as rotas da nossa API:
JavaScript
// backend/index.js
import express from 'express';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import cors from 'cors';
dotenv.config(); // Carrega as variáveis de ambiente do arquivo .env
const app = express();
const port = process.env.PORT || 3000; // Define a porta do servidor
app.use(cors()); // Habilita o CORS para permitir requisições do frontend
app.use(express.json()); // Middleware para analisar o corpo das requisições como JSON
// Conexão com o MongoDB
mongoose.connect(process.env.MONGO_URL, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => console.log('Conectado ao MongoDB'))
.catch((err) => console.error('Erro ao conectar ao MongoDB:', err));
app.listen(port, () => {
console.log(`Servidor rodando na porta ${port}`);
});Neste arquivo:
- Importamos as bibliotecas necessárias.
- Carregamos as variáveis de ambiente do arquivo
.envusandodotenv.config(). - Criamos uma instância do aplicativo Express.
- Definimos a porta em que o servidor irá rodar, utilizando a variável de ambiente
PORTou o valor padrão 3000. - Utilizamos os middlewares
cors()para habilitar o CORS eexpress.json()para que nosso servidor possa entender as requisições com corpo em formato JSON. - Estabelecemos a conexão com o banco de dados MongoDB utilizando a URL armazenada na variável de ambiente
MONGO_URL(que configuraremos no próximo passo). - Finalmente, iniciamos o servidor Express para que ele comece a escutar as requisições na porta definida.
Conectando ao Banco de Dados MongoDB (backend/.env e backend/config/db.js)
Para persistir os dados dos nossos usuários, precisamos de um banco de dados. Optamos pelo MongoDB, um banco de dados NoSQL flexível e escalável. No vídeo, o processo de criação de um banco de dados no MongoDB Atlas (a plataforma de serviços de nuvem do MongoDB) é demonstrado [15:17]. Após criar o cluster e configurar o acesso à rede, obtemos uma URL de conexão.
Essa URL de conexão, que contém informações de autenticação, é uma informação sensível e não deve ser diretamente codificada em nosso aplicativo. É aqui que entra o arquivo .env (criado na raiz da pasta backend). Nele, armazenamos a URL de conexão como uma variável de ambiente:
MONGO_URL=mongodb+srv://<seu_usuario>:<sua_senha>@<seu_cluster>.mongodb.net/<nome_do_banco>?retryWrites=true&w=majority
PORT=3000Lembre-se de substituir <seu_usuario>, <sua_senha> e <seu_cluster> pelas suas informações reais do MongoDB Atlas.
Embora tenhamos diretamente configurado a conexão no index.js neste exemplo, em projetos maiores, é comum criar um arquivo separado para a configuração do banco de dados, como backend/config/db.js, para manter o código mais organizado.
Criando o Modelo de Usuário com Mongoose (backend/domains/user/model.js)
Agora que estamos conectados ao banco de dados, precisamos definir a estrutura dos dados dos nossos usuários. Com o Mongoose, fazemos isso definindo um "modelo". Criamos uma pasta domains dentro de backend, e dentro dela uma pasta user. Dentro da pasta user, criamos o arquivo model.js.
// backend/domains/user/model.js
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
const userSchema = new mongoose.Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
}, { timestamps: true }); // Adiciona campos de createdAt e updatedAt automaticamente
// Middleware para encriptar a senha antes de salvar
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next();
try {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
const User = mongoose.model('User', userSchema);
export default User;Neste arquivo, definimos o esquema para os nossos usuários, incluindo os campos name, email (que deve ser único), e password (que será obrigatório). O campo timestamps: true adiciona automaticamente os campos createdAt e updatedAt ao documento.
Além disso, implementamos um middleware usando userSchema.pre('save', ...) para encriptar a senha do usuário antes de salvá-la no banco de dados. Utilizamos a biblioteca bcryptjs para gerar um "salt" e, em seguida, aplicar um hash seguro à senha. Isso garante que as senhas dos usuários não sejam armazenadas em texto plano no nosso banco de dados, aumentando a segurança da nossa aplicação.
Finalmente, criamos o modelo User a partir do esquema e o exportamos para que possamos utilizá-lo em outras partes do nosso backend.
Definindo as Rotas de Usuário (backend/domains/user/routes.js)
Agora precisamos definir os endpoints da nossa API que lidarão com as requisições relacionadas aos usuários, como o registro e o login. Criamos um arquivo routes.js dentro da pasta backend/domains/user.
// backend/domains/user/routes.js
import express from 'express';
import User from './model.js';
const router = express.Router();
// Rota para registrar um novo usuário
router.post('/register', async (req, res) => {
const { name, email, password } = req.body;
try {
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'Este e-mail já está registrado.' });
}
const newUser = new User({ name, email, password });
const savedUser = await newUser.save();
res.status(201).json(savedUser);
} catch (error) {
res.status(500).json({ message: 'Erro ao registrar o usuário.', error: error.message });
}
});
// Rota para buscar todos os usuários (apenas para fins de teste)
router.get('/users', async (req, res) => {
try {
const users = await User.find();
res.json(users);
} catch (error) {
res.status(500).json({ message: 'Erro ao buscar usuários.', error: error.message });
}
});
export default router;Neste arquivo:
- Importamos o
expresspara criar um router e o modeloUserque definimos anteriormente. - Definimos uma rota
POSTpara/register. Quando um cliente enviar uma requisição para este endpoint com os dados de nome, e-mail e senha no corpo da requisição, nosso servidor irá:- Verificar se já existe um usuário com o mesmo e-mail no banco de dados. Se existir, retorna um erro.
- Criar uma nova instância do modelo
Usercom os dados recebidos. - Salvar o novo usuário no banco de dados. O middleware
pre('save')que definimos no modelo irá garantir que a senha seja encriptada antes de ser salva. - Retornar uma resposta com o usuário salvo e o status 201 (Criado).
- Em caso de erro, retornar uma resposta com o status 500 (Erro Interno do Servidor) e uma mensagem de erro.
- Definimos uma rota
GETpara/users(principalmente para fins de teste durante o desenvolvimento) que busca todos os usuários no banco de dados e os retorna como um JSON.
Finalmente, precisamos importar e usar este router no nosso arquivo principal backend/index.js:
// backend/index.js (modificado)
import express from 'express';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
import cors from 'cors';
import userRoutes from './domains/user/routes.js'; // Importa as rotas de usuário
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
mongoose.connect(process.env.MONGO_URL, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => console.log('Conectado ao MongoDB'))
.catch((err) => console.error('Erro ao conectar ao MongoDB:', err));
app.use('/api/user', userRoutes); // Usa as rotas de usuário sob o prefixo /api/user
app.listen(port, () => {
console.log(`Servidor rodando na porta ${port}`);
});Com essa configuração, todas as rotas definidas no arquivo backend/domains/user/routes.js estarão acessíveis através do prefixo /api/user. Por exemplo, a rota de registro estará acessível em /api/user/register.
Organização é a Chave: Separando Responsabilidades
Ao longo desta aula, vimos a importância de organizar nosso código de forma lógica. Separamos a lógica de conexão com o banco de dados, os modelos de dados e as rotas em arquivos e pastas distintos.
Aula 4 – Implementando a Funcionalidade de Login e Registro no HashBNB
Nesta quarta aula do nosso projeto HashBNB, uma aplicação semelhante ao Airbnb construída com o MERN Stack, vamos conectar o frontend ao backend, implementando as funcionalidades de login e registro. Vamos capturar os dados do usuário no frontend, enviar requisições para a API, tratar erros de autenticação, e configurar redirecionamentos automáticos após o login. Também criaremos a página de registro e ajustaremos o comportamento do cabeçalho com base no estado do usuário.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Implementando a Funcionalidade de Login
Na Aula 3, configuramos a tela de login e o backend com Node.js, Express, e MongoDB. Agora, vamos implementar a lógica para capturar os dados do formulário de login, enviar uma requisição à API, e tratar as respostas.
Capturando Dados do Formulário com useState
No arquivo Login.jsx, usamos o hook useState do React para gerenciar os valores dos campos de e-mail e senha. Isso é necessário porque, no React, variáveis normais não causam re-renderização do componente quando alteradas, mas variáveis de estado sim. Aqui está a estrutura inicial para capturar os inputs:
- Definimos estados para email e password com useState:
const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - Adicionamos o evento onChange nos inputs para atualizar os estados conforme o usuário digita:
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Digite seu e-mail" className="w-full rounded-full border border-gray-300 px-4 py-2" /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Digite sua senha" className="w-full rounded-full border border-gray-300 px-4 py-2" /> - No formulário, adicionamos o evento onSubmit para evitar o comportamento padrão (recarregar a página) e processar os dados:
const handleSubmit = async (e) => { e.preventDefault(); // Lógica de envio será adicionada aqui };
Configurando a Comunicação com a API via Axios
Para enviar os dados do formulário à API, usamos a biblioteca Axios, que simplifica requisições HTTP. Primeiro, instalamos o Axios no frontend:
cd frontend
npm install axiosNo arquivo App.jsx, configuramos a URL base do Axios para facilitar as requisições. Usamos a variável de ambiente do Vite (import.meta.env) para definir a URL da API, que aponta para o backend em http://localhost:4000 durante o desenvolvimento:
import axios from "axios";
axios.defaults.baseURL = import.meta.env.VITE_AXIOS_BASE_URL;No Login.jsx, implementamos a requisição POST para a rota /users/login. Validamos se os campos email e password estão preenchidos antes de enviar a requisição. Em caso de erro, exibimos um alerta com a mensagem retornada pela API:
const handleSubmit = async (e) => {
e.preventDefault();
if (email && password) {
try {
const { data: userDoc } = await axios.post("/users/login", {
email,
password,
});
setUser(userDoc);
setRedirect(true);
} catch (error) {
alert(`Deu um erro ao logar: ${error.response.data}`);
}
} else {
alert("Você precisa preencher o e-mail e a senha!");
}
};Implementando Redirecionamento Automático
Após um login bem-sucedido, queremos redirecionar o usuário para a página inicial. Para isso, criamos um estado redirect com useState e usamos o componente Navigate do React Router:
const [redirect, setRedirect] = useState(false);
if (redirect || user) return <Navigate to="/" />;Se redirect for true (após um login bem-sucedido) ou se já houver um usuário logado (user), o componente redireciona automaticamente para a página inicial (/).
Passando Dados do Usuário para o Header
Para exibir o nome do usuário no cabeçalho e ajustar a navegação, gerenciamos o estado do usuário no nível do componente App.jsx e o passamos como prop para os componentes Header e Login:
const [user, setUser] = useState(null);No App.jsx, passamos user para o Header e user e setUser para o Login:
<Header user={user} />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login user={user} setUser={setUser} />} />
</Routes>No Header.jsx, usamos a prop user para exibir o nome do usuário logado e ajustar o link do botão de usuário. Se o usuário estiver logado, o botão leva à página de conta (/account); caso contrário, leva à página de login (/login):
<Link
to={user ? "/account" : "/login"}
className="flex items-center gap-2 rounded-full border border-gray-300 py-2 pr-4 pl-6 shadow-md"
>
<svg>...</svg>
<svg>...</svg>
{user ? (
<p className="max-w-20 truncate sm:max-w-32">{user.name}</p>
) : (
<></>
)}
</Link>Configurando a Rota de Login no Backend
No backend, criamos a rota POST /users/login no arquivo routes.js para autenticar o usuário. A rota verifica se o e-mail existe no banco de dados e compara a senha fornecida com a senha criptografada usando bcrypt.compareSync:
router.post("/login", async (req, res) => {
connectDb();
const { email, password } = req.body;
try {
const userDoc = await User.findOne({ email });
if (userDoc) {
const passwordCorrect = bcrypt.compareSync(password, userDoc.password);
const { name, _id } = userDoc;
passwordCorrect
? res.json({ name, email, _id })
: res.status(400).json("Senha inválida!");
} else {
res.status(400).json("Usuário não encontrado!");
}
} catch (error) {
res.status(500).json(error);
}
});- Verificação do Usuário: Usamos User.findOne({ email }) para buscar um usuário pelo e-mail.
- Validação da Senha: Comparamos a senha fornecida com a senha criptografada usando bcrypt.compareSync.
- Resposta: Se a senha estiver correta, retornamos um objeto com name, email, e _id. Caso contrário, retornamos um erro com status 400.
Resolvendo Problemas de CORS
Ao testar a comunicação entre o frontend (porta 5174) e o backend (porta 4000), encontramos um erro de CORS (Cross-Origin Resource Sharing). Para resolvê-lo, adicionamos o middleware cors no backend, no arquivo index.js:
import cors from "cors";
app.use(cors());Isso permite que o frontend faça requisições ao backend sem restrições de origem.
Criando a Página de Registro
A página de registro (Register.jsx) é semelhante à de login, mas inclui um campo adicional para o nome do usuário. Copiamos a estrutura do Login.jsx e fizemos os ajustes necessários:
import React, { useState } from "react";
import { Link, Navigate } from "react-router-dom";
import axios from "axios";
const Register = ({ setUser }) => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [redirect, setRedirect] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
// Lógica de registro será implementada
};
if (redirect) return <Navigate to="/" />;
return (
<section className="flex items-center">
<div className="mx-auto flex w-full max-w-96 flex-col items-center gap-4">
<h1 className="text-3xl font-bold">Faça seu cadastro</h1>
<form className="flex w-full flex-col gap-2" onSubmit={handleSubmit}>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Digite seu nome"
className="w-full rounded-full border border-gray-300 px-4 py-2"
/>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Digite seu e-mail"
className="w-full rounded-full border border-gray-300 px-4 py-2"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Digite sua senha"
className="w-full rounded-full border border-gray-300 px-4 py-2"
/>
<button className="bg-primary-400 w-full cursor-pointer rounded-full border border-gray-300 px-4 py-2 font-bold text-white">
Registrar
</button>
</form>
<p>
Já tem uma conta?{" "}
<Link to="/login" className="font-semibold underline">
Logue aqui!
</Link>
</p>
</div>
</section>
);
};
export default Register;Embora a lógica de envio para a rota /users (registro) ainda não tenha sido implementada, a rota já existe no backend (routes.js) e está pronta para criar novos usuários com nome, e-mail e senha criptografada.
Salvando Alterações no GitHub
Após implementar as mudanças, salvamos as alterações no repositório do GitHub:
git add .
git commit -m "Fim da aula 4"
git push origin mainEncerramento da Aula 4
Nesta aula, conectamos o frontend ao backend, implementamos a funcionalidade de login com validação de e-mail e senha, configuramos redirecionamentos automáticos, e criamos a base da página de registro. Também ajustamos o cabeçalho para refletir o estado do usuário logado.
Aula 5 – Implementando Login Persistente e Finalizando o Registro no HashBNB
Nesta quinta aula do projeto HashBNB, uma aplicação semelhante ao Airbnb construída com o MERN Stack, vamos implementar o login persistente usando cookies e JSON Web Tokens (JWT). Isso garante que o usuário permaneça logado mesmo após atualizar a página. Além disso, finalizaremos a funcionalidade de registro, permitindo login automático após o cadastro, e criaremos uma rota para buscar os dados do usuário logado. Vamos configurar cookies entre domínios e usar o hook useEffect para carregar o estado do usuário ao iniciar a aplicação.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Entendendo Cookies e Login Persistente
Na Aula 4, implementamos o login, mas ao atualizar a página (F5), o estado do usuário era perdido. Para resolver isso, usaremos cookies para armazenar informações de autenticação no navegador. Cookies são dados salvos localmente que acompanham as requisições HTTP, permitindo que o backend identifique o usuário.
Vamos usar JSON Web Tokens (JWT), um padrão para criar tokens criptografados que contêm informações do usuário (como name, email, e _id). Esses tokens serão armazenados nos cookies e enviados automaticamente em cada requisição, garantindo a persistência do login.
Configurando JWT no Backend
Primeiro, instalamos a biblioteca jsonwebtoken no backend para gerar e verificar tokens:
cd backend
npm install jsonwebtokenNo arquivo routes.js, importamos o jsonwebtoken e configuramos a variável de ambiente JWT_SECRET_KEY, definida no arquivo .env:
import jwt from "jsonwebtoken";
const { JWT_SECRET_KEY } = process.env;No arquivo .env, adicionamos a chave secreta para assinar os tokens:
JWT_SECRET_KEY=qualquer_coisa_secretaModificando a Rota de Login
Na rota POST /users/login, após verificar o e-mail e a senha, geramos um token JWT com as informações do usuário (name, email, _id) e o armazenamos em um cookie chamado token:
router.post("/login", async (req, res) => {
connectDb();
const { email, password } = req.body;
try {
const userDoc = await User.findOne({ email });
if (userDoc) {
const passwordCorrect = bcrypt.compareSync(password, userDoc.password);
const { name, _id } = userDoc;
if (passwordCorrect) {
const newUserObj = { name, email, _id };
const token = jwt.sign(newUserObj, JWT_SECRET_KEY);
res.cookie("token", token).json(newUserObj);
} else {
res.status(400).json("Senha inválida!");
}
} else {
res.status(400).json("Usuário não encontrado!");
}
} catch (error) {
res.status(500).json(error);
}
});- Geração do Token: Usamos jwt.sign(newUserObj, JWT_SECRET_KEY) para criar o token com os dados do usuário.
- Cookie: O método res.cookie("token", token) define o cookie na resposta, que será armazenado pelo navegador.
Configurando Cookies no Backend
Para ler cookies nas requisições, instalamos o pacote cookie-parser:
npm install cookie-parserNo arquivo index.js, importamos e usamos o cookie-parser como middleware:
import cookieParser from "cookie-parser";
app.use(cookieParser());Resolvendo Problemas de CORS
Para permitir que cookies sejam enviados entre o frontend (http://localhost:5174) e o backend (http://localhost:4000), configuramos o middleware cors com suporte a credentials:
app.use(
cors({
origin: "http://localhost:5174",
credentials: true,
})
);No frontend, no arquivo App.jsx, habilitamos o envio de cookies nas requisições do Axios:
axios.defaults.withCredentials = true;Isso garante que os cookies sejam incluídos nas requisições e armazenados corretamente pelo navegador.
Criando a Rota de Perfil
Criamos uma nova rota GET /users/profile no routes.js para verificar o token armazenado nos cookies e retornar os dados do usuário:
router.get("/profile", async (req, res) => {
const { token } = req.cookies;
if (token) {
try {
const userInfo = jwt.verify(token, JWT_SECRET_KEY);
res.json(userInfo);
} catch (error) {
res.status(500).json(error);
}
} else {
res.json(null);
}
});- Leitura do Cookie: Usamos req.cookies para acessar o token.
- Verificação do Token: O método jwt.verify(token, JWT_SECRET_KEY) decodifica o token e retorna os dados do usuário (name, email, _id).
- Resposta: Se o token for válido, retornamos os dados do usuário; caso contrário, retornamos null.
Carregando o Usuário Logado com useEffect
No frontend, no arquivo App.jsx, usamos o hook useEffect para fazer uma requisição GET para /users/profile quando o componente é montado, verificando se há um usuário logado:
import { useEffect, useState } from "react";
useEffect(() => {
const axiosGet = async () => {
const { data } = await axios.get("/users/profile");
setUser(data);
};
axiosGet();
}, []);- useEffect: Executa a requisição apenas uma vez, quando o componente é montado, devido à lista de dependências vazia ([]).
- Função Assíncrona: Definimos axiosGet como uma função assíncrona para lidar com a requisição, já que callbacks do useEffect não podem ser assíncronas diretamente.
- Atualização do Estado: Se a resposta contiver dados do usuário, atualizamos o estado com setUser(data).
Isso garante que, ao carregar a aplicação, o usuário logado seja restaurado com base no token armazenado nos cookies.
Finalizando a Funcionalidade de Registro
Na Aula 4, criamos a página de registro, mas a lógica de envio estava incompleta. Agora, no arquivo Register.jsx, implementamos a requisição POST para /users e configuramos o login automático após o cadastro:
const handleSubmit = async (e) => {
e.preventDefault();
if (email && password && name) {
try {
const { data: userDoc } = await axios.post("/users", {
name,
email,
password,
});
setUser(userDoc);
setRedirect(true);
} catch (error) {
alert(`Deu um erro ao cadastrar o usuário: ${JSON.stringify(error)}`);
}
} else {
alert("Você precisa preencher o e-mail, o nome e a senha!");
}
};- Validação: Verificamos se name, email, e password estão preenchidos.
- Requisição: Enviamos os dados para a rota /users, que cria o usuário no banco.
- Login Automático: Após o cadastro, atualizamos o estado com setUser(userDoc) e redirecionamos para a página inicial com setRedirect(true).
No backend, a rota POST /users foi atualizada para gerar um token JWT e definir o cookie, semelhante à rota de login:
router.post("/", async (req, res) => {
connectDb();
const { name, email, password } = req.body;
const encryptedPassword = bcrypt.hashSync(password, bcryptSalt);
try {
const newUserDoc = await User.create({
name,
email,
password: encryptedPassword,
});
const { _id } = newUserDoc;
const newUserObj = { name, email, _id };
const token = jwt.sign(newUserObj, JWT_SECRET_KEY);
res.cookie("token", token).json(newUserObj);
} catch (error) {
res.status(500).json(error);
}
});Isso permite que o usuário seja cadastrado, logado automaticamente, e tenha o token armazenado nos cookies.
Salvando Alterações no GitHub
Após implementar as mudanças, salvamos as alterações no repositório do GitHub:
git add .
git commit -m "Fim da aula 5"
git push origin mainEncerramento da Aula 5
Nesta aula, implementamos o login persistente com cookies e JWT, configuramos a comunicação entre frontend e backend com suporte a cookies, criamos uma rota para buscar dados do usuário logado, e finalizamos a funcionalidade de registro com login automático. Agora, a aplicação HashBNB mantém o estado do usuário mesmo após atualizações da página. Pratique o que aprendemos, explore os arquivos no repositório do GitHub, e prepare-se para continuar construindo a aplicação na próxima etapa!
Aula 6 - Sistema de Conta e Contexto de Usuário
Nesta aula, vamos avançar no desenvolvimento do HashBNB, implementando o sistema de conta do usuário e um contexto global para gerenciar o estado de autenticação. Nosso foco é:
- Criar um contexto de usuário para gerenciar o estado global (UserContext).
- Ajustar as páginas de login e registro para usar o contexto.
- Desenvolver a página de conta com subpáginas dinâmicas (profile, bookings, places).
- Implementar a funcionalidade de logout no backend e frontend.
- Atualizar o header para refletir o estado do usuário autenticado.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Passos Realizados
1. Configurando o Contexto de Usuário
Para gerenciar o estado do usuário de forma global, criamos o arquivo UserContext.jsx. Esse contexto usa o createContext do React e um provedor (UserContextProvider) que envolve toda a aplicação, permitindo que os componentes acessem o estado do usuário e a função para atualizá-lo.
- Criamos o UserContext com createContext(null).
- No UserContextProvider, usamos useState para gerenciar o estado do usuário.
- Com useEffect, fazemos uma requisição GET para /users/profile ao carregar a aplicação, atualizando o estado com os dados do usuário autenticado.
- Exportamos o hook useUserContext para facilitar o acesso ao contexto.
Código Relevante (UserContext.jsx):
import axios from "axios";
import { createContext, useContext, useEffect, useState } from "react";
export const UserContext = createContext(null);
export const useUserContext = () => useContext(UserContext);
export const UserContextProvider = ({ children }) => {
const [user, setUser] = useState(null);
useEffect(() => {
const axiosGet = async () => {
const { data } = await axios.get("/users/profile");
setUser(data);
};
axiosGet();
}, []);
return (
<UserContext.Provider value={{ user, setUser }}>
{children}
</UserContext.Provider>
);
};2. Ajustando o App.jsx
Modificamos o App.jsx para integrar o UserContextProvider e ajustar o roteamento da aplicação.
- Removemos a passagem direta de props user e setUser, que antes eram gerenciadas localmente.
- Envolvemos o BrowserRouter com o UserContextProvider para que todos os componentes tenham acesso ao contexto.
- Mantivemos as rotas existentes, incluindo a rota dinâmica /account/:subpage/:action? para suportar as subpáginas da conta.
Código Relevante (App.jsx):
import axios from "axios";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { UserContextProvider } from "./contexts/UserContext";
import Home from "./pages/Home";
import Login from "./pages/Login";
import Register from "./pages/Register";
import Account from "./pages/Account";
import Header from "./components/Header";
axios.defaults.baseURL = import.meta.env.VITE_AXIOS_BASE_URL;
axios.defaults.withCredentials = true;
function App() {
return (
<UserContextProvider>
<BrowserRouter>
<Header />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/account/:subpage/:action?" element={<Account />} />
</Routes>
</BrowserRouter>
</UserContextProvider>
);
}
export default App;3. Atualizando Login e Registro
Ajustamos as páginas Login.jsx e Register.jsx para usar o UserContext em vez de props passadas diretamente.
- Importamos useUserContext para acessar user e setUser.
- Em Login.jsx, adicionamos uma verificação para redirecionar à página inicial se o usuário já estiver logado.
- Em ambos, atualizamos o estado do usuário com setUser após um login ou registro bem-sucedido, mantendo a lógica de requisições axios.post para /users (registro) e /users/login (login).
Código Relevante (Login.jsx):
import React, { useState } from "react";
import { Link, Navigate } from "react-router-dom";
import axios from "axios";
import { useUserContext } from "../contexts/UserContext";
const Login = () => {
const { user, setUser } = useUserContext();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [redirect, setRedirect] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
if (email && password) {
try {
const { data: userDoc } = await axios.post("/users/login", {
email,
password,
});
setUser(userDoc);
setRedirect(true);
} catch (error) {
alert(`Deu um erro ao logar: ${JSON.stringify(error)}`);
}
} else {
alert("Você precisa preencher o e-mail e a senha!");
}
};
if (redirect || user) return <Navigate to="/" />;
return (
<section className="flex items-center">
<div className="mx-auto flex w-full max-w-96 flex-col items-center gap-4">
<h1 className="text-3xl font-bold">Faça seu login</h1>
<form className="flex w-full flex-col gap-2" onSubmit={handleSubmit}>
<input
type="email"
className="w-full rounded-full border border-gray-300 px-4 py-2"
placeholder="Digite seu e-mail"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
className="w-full rounded-full border border-gray-300 px-4 py-2"
placeholder="Digite sua senha"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button className="bg-primary-400 w-full cursor-pointer rounded-full border border-gray-300 px-4 py-2 font-bold text-white">
Login
</button>
</form>
<p>
Ainda não tem uma conta?{" "}
<Link to="/register" className="font-semibold underline">
Registre-se aqui!
</Link>
</p>
</div>
</section>
);
};
export default Login;4. Criando a Página de Conta
Implementamos a página Account.jsx, que suporta subpáginas dinâmicas (profile, bookings, places) com navegação via botões.
- Usamos useParams para capturar o parâmetro subpage da URL.
- Criamos botões de navegação com estilização condicional via Tailwind CSS, destacando a subpágina ativa.
- Renderizamos condicionalmente os componentes AccProfile (para profile) e AccPlaces (para places). A subpágina bookings será implementada futuramente.
Código Relevante (Account.jsx):
import React from "react";
import { Link, useParams } from "react-router-dom";
import AccProfile from "../components/AccProfile";
import AccPlaces from "../components/AccPlaces";
const Account = () => {
const { subpage } = useParams();
const buttonClass = (button) => {
let finalClass =
"hover:bg-primary-400 cursor-pointer rounded-full px-4 py-2 transition hover:text-white";
if (button === subpage) finalClass += " bg-primary-400 text-white";
return finalClass;
};
return (
<section className="p-8">
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8">
<div className="flex gap-2">
<Link to="/account/profile" className={buttonClass("profile")}>
Perfil
</Link>
<Link to="/account/bookings" className={buttonClass("bookings")}>
Reservas
</Link>
<Link to="/account/places" className={buttonClass("places")}>
Lugares
</Link>
</div>
{subpage === "profile" && <AccProfile />}
{subpage === "places" && <AccPlaces />}
</div>
</section>
);
};
export default Account;5. Desenvolvendo o Componente de Perfil
Criamos o AccProfile.jsx para exibir informações do usuário e implementar a funcionalidade de logout.
- Usamos useUserContext para acessar user e setUser.
- Exibimos o nome e email do usuário.
- Adicionamos um botão de logout que faz uma requisição POST para /users/logout, limpa o estado do usuário (setUser(null)) e redireciona para a página inicial.
Código Relevante (AccProfile.jsx):
import React, { useState } from "react";
import axios from "axios";
import { Navigate } from "react-router-dom";
import { useUserContext } from "../contexts/UserContext";
const AccProfile = () => {
const { user, setUser } = useUserContext();
const [redirect, setRedirect] = useState(false);
const logout = async () => {
try {
const { data } = await axios.post("/users/logout");
console.log(data);
setUser(null);
setRedirect(true);
} catch (error) {
alert(JSON.stringify(error));
}
};
if (redirect) return <Navigate to="/" />;
if (!user) return <></>;
return (
<div className="flex flex-col items-center gap-4">
<p>
Logado como {user.name} ({user.email})
</p>
<button
onClick={logout}
className="bg-primary-400 min-w-44 cursor-pointer rounded-full px-4 py-2 text-white transition"
>
Logout
</button>
</div>
);
};
export default AccProfile;6. Atualizando o Header
Modificamos o Header.jsx para integrar com o UserContext e melhorar a navegação.
- Usamos useUserContext para acessar o estado do usuário.
- Atualizamos o link do ícone de usuário para redirecionar para /account/profile (se logado) ou /login (se não logado).
- Exibimos o nome do usuário (truncado) ao lado do ícone, quando autenticado.
Código Relevante (Header.jsx):
import React from "react";
import { Link } from "react-router-dom";
import { useUserContext } from "../contexts/UserContext";
const Header = () => {
const { user } = useUserContext();
return (
<header className="shadow-md">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-4 sm:px-8">
<Link to="/" className="flex items-center">
<img
className="h-10"
src="https://cdn.prod.website-files.com/61b9e0dd381626819c8d4f83/65e2198d48039ba6444f602b_logo%20hashtag%20-%20h.webp"
alt="Logo da Hashtag"
/>
<p className="text-primary-400 text-2xl font-bold">ashbnb</p>
</Link>
<Link
to={user ? "/account/profile" : "/login"}
className="flex items-center gap-2 rounded-full border border-gray-300 py-2 pr-4 pl-6 shadow-md"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="size-5 text-gray-600"
>
<path
fillRule="evenodd"
d="M3 6.75A.75.75 0 0 1 3.75 6h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 6.75ZM3 12a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1 3 12Zm0 5.25a.75.75 0 0 1 .75-.75h16.5a.75.75 0 0 1 0 1.5H3.75A.75.75 0 0 1-.75-.75Z"
clipRule="evenodd"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="size-8 text-gray-600"
>
<path
fillRule="evenodd"
d="M18.685 19.097A9.723 9.723 0 0 0 21.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 0 0 3.065 7.097A9.716 9.716 0 0 0 12 21.75a9.716 9.716 0 0 0 6.685-2.653Zm-12.54-1.285A7.486 7.486 0 0 1 12 15a7.486 7.486 0 0 1 5.855 2.812A8.224 8.224 0 0 1 12 20.25a8.224 8.224 0 0 1-5.855-2.438ZM15.75 9a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
clipRule="evenodd"
/>
</svg>
{user ? (
<p className="max-w-20 truncate sm:max-w-32">{user.name}</p>
) : (
<></>
)}
</Link>
</div>
</header>
);
};
export default Header;7. Implementando Logout no Backend
Adicionamos a rota de logout no backend para limpar o cookie de autenticação.
- Criamos a rota POST /users/logout em routes.js.
- Usamos res.clearCookie("token") para remover o cookie token.
- Retornamos uma mensagem de sucesso.
Código Relevante (routes.js):
router.post("/logout", (req, res) => {
res.clearCookie("token").json("Deslogado com sucesso!");
});Encerramento da Aula 6
Completamos a implementação do sistema de conta, com uma página de perfil funcional, navegação entre subpáginas e um contexto global para gerenciar o estado do usuário. A funcionalidade de logout está integrada, e o header reflete o estado de autenticação. Estamos prontos para implementar a funcionalidade de lugares na próxima aula.
Aula 7 - Cadastro de Acomodações
Nesta aula, o foco é a a implementação da funcionalidade de cadastro de acomodações no HashBNB. Criamos a subpágina de lugares, um formulário para adicionar novos lugares e um componente para selecionar comodidades. Também corrigimos um problema no backend relacionado ao jwt.sign. Nossos objetivos são:
- Corrigir a rota de login no backend para usar jwt.sign assincronamente.
- Criar a subpágina de lugares na seção de conta.
- Desenvolver um formulário para cadastro de novas acomodações.
- Implementar um componente de comodidades com checkboxes.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Passos Realizados
1. Corrigindo o Backend
Ajustamos a rota de login no backend para corrigir o uso do jwt.sign, que agora é tratado de forma assíncrona.
- Na rota POST /users/login, validamos o usuário e a senha com bcrypt.compareSync.
- Geramos o token JWT usando a callback assíncrona de jwt.sign, armazenando-o em um cookie.
- Retornamos o objeto do usuário (name, email, _id) na resposta.
Código Relevante (routes.js):
router.post("/login", async (req, res) => {
connectDb();
const { email, password } = req.body;
try {
const userDoc = await User.findOne({ email });
if (userDoc) {
const passwordCorrect = bcrypt.compareSync(password, userDoc.password);
const { name, _id } = userDoc;
if (passwordCorrect) {
const newUserObj = { name, email, _id };
jwt.sign(newUserObj, JWT_SECRET_KEY, {}, (error, token) => {
if (error) {
console.error(error);
res.status(500).json(error);
return;
}
res.cookie("token", token).json(newUserObj);
});
} else {
res.status(400).json("Senha inválida!");
}
} else {
res.status(400).json("Usuário não encontrado!");
}
} catch (error) {
res.status(500).json(error);
}
});2. Criando a Subpágina de Lugares
Desenvolvemos o componente AccPlaces.jsx para gerenciar a subpágina de lugares na seção de conta.
- Usamos useParams para capturar o parâmetro action da URL.
- Exibimos um botão "Adicionar novo lugar" que redireciona para /account/places/new quando action não é new.
- Renderizamos o componente NewPlace quando action é new.
Código Relevante (AccPlaces.jsx):
import React from "react";
import { Link } from "react-router-dom";
import { useParams } from "react-router-dom";
import NewPlace from "./NewPlace";
const AccPlaces = () => {
const { action } = useParams();
return (
<div className="flex w-full max-w-7xl flex-col items-center">
{action !== "new" ? (
<Link
to="/account/places/new"
className="hover:bg-primary-500 bg-primary-400 flex min-w-44 cursor-pointer gap-2 rounded-full px-4 py-2 text-white transition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
Adicionar novo lugar
</Link>
) : (
<NewPlace />
)}
</div>
);
};
export default AccPlaces;3. Desenvolvendo o Formulário de Nova Acomodação
Criamos o componente NewPlace.jsx, que contém um formulário para cadastrar novas acomodações.
- Usamos useState para gerenciar os campos: title, city, photos, description, extras, price, checkin, checkout e guests.
- O formulário inclui:
- Título: Input de texto.
- Cidade e País: Input de texto.
- Fotos: Input para links de fotos e um botão de upload (não funcional ainda).
- Descrição: Textarea.
- Comodidades: Componente Perks.
- Informações Extras: Textarea.
- Restrições e Preço: Inputs para preço, check-in, check-out e número de hóspedes.
- Estilizamos com Tailwind CSS, reutilizando classes de inputs.
Código Relevante (NewPlace.jsx):
import { useState } from "react";
import Perks from "./Perks";
const NewPlace = () => {
const [title, setTitle] = useState("");
const [city, setCity] = useState("");
const [photos, setPhotos] = useState("");
const [description, setDescription] = useState("");
const [extras, setExtras] = useState("");
const [price, setPrice] = useState("");
const [checkin, setCheckin] = useState("");
const [checkout, setCheckout] = useState("");
const [guests, setGuests] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
};
return (
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-6 px-8">
<div className="flex flex-col gap-1">
<label htmlFor="title" className="ml-2 text-2xl font-bold">
Título
</label>
<input
type="text"
placeholder="Digite o título do seu anúncio"
className="rounded-full border border-gray-300 px-4 py-2"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="city" className="ml-2 text-2xl font-bold">
Cidade e País
</label>
<input
type="text"
placeholder="Digite a cidade e país do seu anúncio"
className="rounded-full border border-gray-300 px-4 py-2"
id="city"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="photos" className="ml-2 text-2xl font-bold">
Fotos
</label>
<div className="flex gap-2">
<input
type="text"
placeholder="Adicione uma foto pelo link dela"
className="grow rounded-full border border-gray-300 px-4 py-2"
id="photos"
value={photos}
onChange={(e) => setPhotos(e.target.value)}
/>
<button className="cursor-pointer rounded-full border border-gray-300 bg-gray-100 px-4 py-2 transition hover:bg-gray-200">
Enviar foto
</button>
</div>
</div>
<button className="hover:bg-primary-500 bg-primary-400 min-w-44 cursor-pointer rounded-full px-4 py-2 text-white transition">
Salvar informações
</button>
</form>
);
};
export default NewPlace;4. Criando o Componente de Comodidades
Desenvolvemos o Perks.jsx para permitir a seleção de comodidades via checkboxes.
- Usamos useState para gerenciar o array de comodidades selecionadas.
- Criamos uma grade de checkboxes com opções como Wi-Fi, estacionamento, TV, rádio, pets e entrada privada.
- Cada opção tem um ícone SVG e é estilizada com Tailwind CSS.
Código Relevante (Perks.jsx):
import React, { useState } from "react";
const Perks = () => {
const [perks, setPerks] = useState([]);
const handleClick = (target) => {
const newPerks = target.checked
? [...perks, target.value]
: [...perks].filter((perk) => perk !== target.value);
setPerks(newPerks);
};
return (
<div className="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] gap-4">
<label
htmlFor="wifi"
className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 px-4 py-3"
>
<input
type="checkbox"
id="wifi"
value={"wifi"}
onChange={(e) => handleClick(e.target)}
/>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.288 15.038a5.25 5.25 0 0 1 7.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 0 1 1.06 0Z"
/>
</svg>
Wifi
</label>
{/* Outras opções de comodidades */}
</div>
);
};
export default Perks;5. Integrando com a Página de Conta
Atualizamos o Account.jsx para incluir a subpágina de lugares.
- Adicionamos o componente AccPlaces à renderização condicional para subpage === "places".
- Mantivemos a navegação entre subpáginas.
Código Relevante (Account.jsx):
{subpage === "places" && <AccPlaces />}Encerramento da Aula 7
Implementamos a subpágina de lugares com um formulário para cadastrar acomodações, incluindo campos para título, localização, fotos, descrição, comodidades e restrições. O componente de comodidades permite seleções dinâmicas, e a correção no backend garante a funcionalidade de login. Na próxima aula, adicionaremos a lógica de envio do formulário e integração com o backend.
Aula 8 - Backend para Cadastro de Acomodações
Por tratar do backend, as aulas 8 e 9 serão mais objetivas. Mostraremos os códigos e explicaremos o que cada um deles faz.
Nesta aula vamos criar o backend para cadastrar novas acomodações, incluindo o schema do MongoDB, rotas, autenticação com JWT e testes via ReqBin. O foco será a estrutura do servidor, modelos, rotas e utilitários para autenticação.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Arquivos da Aula 8
backend/domains/places/model.js
Define o schema do MongoDB para os lugares (acomodações).
import { model, Schema } from "mongoose";
const placeSchema = new Schema({
owner: { type: Schema.Types.ObjectId, ref: "User" },
title: String,
city: String,
photos: [String],
description: String,
extras: String,
perks: [String],
price: Number,
checkin: String,
checkout: String,
guests: Number,
});
export default model("Place", placeSchema);backend/domains/places/routes.js
Define as rotas para criar lugares e (posteriormente) fazer upload de imagens por link.
import { Router } from "express";
import Place from "./model.js";
import { JWTVerify } from "../../utils/jwt.js";
import { connectDb } from "../../config/db.js";
import { downloadImage } from "../../utils/imageDownloader.js";
import { __dirname } from "../../server.js";
const router = Router();
router.post("/", async (req, res) => {
connectDb();
const {
title,
city,
photos,
description,
extras,
price,
perks,
checkin,
checkout,
guests,
} = req.body;
try {
const { _id: owner } = await JWTVerify(req);
const newPlaceDoc = await Place.create({
owner,
title,
city,
photos,
description,
extras,
perks,
price,
checkin,
checkout,
guests,
});
res.json(newPlaceDoc);
} catch (error) {
console.error(error);
res.status(500).json("Deu erro ao criar o novo lugar");
}
});
router.post("/upload/link", async (req, res) => {
const { link } = req.body;
try {
const filename = await downloadImage(link, `${__dirname}/tmp/`);
res.json(filename);
} catch (error) {
console.error(error);
res.status(500).json("Deu erro ao baixar a imagem.");
}
});
export default router;backend/domains/users/model.js
Define o schema do MongoDB para usuários (corrigido para conter apenas o modelo).
import { model, Schema } from "mongoose";
const userSchema = new Schema({
name: String,
email: { type: String, unique: true },
password: String,
});
export default model("User", userSchema);backend/domains/users/routes.js
Gerencia rotas de autenticação, criação de usuários, login, logout e perfil.
import "dotenv/config";
import { Router } from "express";
import { connectDb } from "../../config/db.js";
import User from "./model.js";
import bcrypt from "bcryptjs";
import { JWTVerify, JWTSign } from "../../utils/jwt.js";
const router = Router();
const bcryptSalt = bcrypt.genSaltSync();
const { JWT_SECRET_KEY } = process.env;
router.get("/", async (req, res) => {
connectDb();
try {
const userDoc = await User.find();
res.json(userDoc);
} catch (error) {
res.status(500).json(error);
}
});
router.get("/profile", async (req, res) => {
const userInfo = await JWTVerify(req);
res.json(userInfo);
});
router.post("/", async (req, res) => {
connectDb();
const { name, email, password } = req.body;
const encryptedPassword = bcrypt.hashSync(password, bcryptSalt);
try {
const newUserDoc = await User.create({
name,
email,
password: encryptedPassword,
});
const { _id } = newUserDoc;
const newUserObj = { name, email, _id };
try {
const token = await JWTSign(newUserObj);
res.cookie("token", token).json(newUserObj);
} catch (error) {
res.status(500).json("Erro ao assinar com o JWT", error);
}
} catch (error) {
res.status(500).json(error);
throw error;
}
});
router.post("/login", async (req, res) => {
connectDb();
const { email, password } = req.body;
try {
const userDoc = await User.findOne({ email });
if (userDoc) {
const passwordCorrect = bcrypt.compareSync(password, userDoc.password);
const { name, _id } = userDoc;
if (passwordCorrect) {
const newUserObj = { name, email, _id };
try {
const token = await JWTSign(newUserObj);
res.cookie("token", token).json(newUserObj);
} catch (error) {
console.error(error);
res.status(500).json("Erro ao assinar com o JWT");
}
} else {
res.status(400).json("Senha inválida!");
}
} else {
res.status(400).json("Usuário não encontrado!");
}
} catch (error) {
res.status(500).json(error);
}
});
router.post("/logout", (req, res) => {
res.clearCookie("token").json("Deslogado com sucesso!");
});
export default router;backend/routes/index.js
Centraliza as rotas de usuários e lugares.
import { Router } from "express";
import UserRoutes from "../domains/users/routes.js";
import PlaceRoutes from "../domains/places/routes.js";
const router = Router();
router.use("/users", UserRoutes);
router.use("/places", PlaceRoutes);
export default router;backend/utils/jwt.js
Contém funções utilitárias para assinar e verificar tokens JWT.
import "dotenv/config";
import jwt from "jsonwebtoken";
const { JWT_SECRET_KEY } = process.env;
export const JWTVerify = (req) => {
const { token } = req.cookies;
if (token) {
return new Promise((resolve, reject) => {
jwt.verify(token, JWT_SECRET_KEY, {}, (error, userInfo) => {
if (error) {
console.error("Deu algum erro ao verificar com o JWT:", error);
reject(error);
}
resolve(userInfo);
});
});
} else {
return null;
}
};
export const JWTSign = (newUserObj) => {
return new Promise((resolve, reject) => {
jwt.sign(
newUserObj,
JWT_SECRET_KEY,
{ expiresIn: "1d" },
(error, token) => {
if (error) {
console.error("Deu algum erro ao assinar com o JWT:", error);
reject(error);
}
resolve(token);
}
);
});
};backend/index.js
Ponto de entrada do servidor, inicia o app.listen.
import "dotenv/config";
import { app } from "./server.js";
const { PORT } = process.env;
app.listen(PORT, () => {
console.log(`Servidor está rodando na porta ${PORT}`);
});backend/server.js
Configura o servidor Express, middlewares e rotas.
import express from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import routes from "./routes/index.js";
import { fileURLToPath } from "url";
import { dirname } from "node:path";
export const app = express();
export const __filename = fileURLToPath(import.meta.url);
export const __dirname = dirname(__filename);
app.use(express.json());
app.use(cookieParser());
app.use(
cors({
origin: "http://localhost:5174",
credentials: true,
})
);
app.use("/tmp", express.static(__dirname + "/tmp"));
app.use(routes);backend/db.js
Gerencia a conexão com o MongoDB.
import "dotenv/config";
import mongoose from "mongoose";
const { MONGO_URL } = process.env;
export const connectDb = async () => {
try {
await mongoose.connect(MONGO_URL);
console.log("Deu certo ao conectar com o banco!");
} catch (error) {
console.log("NÃO deu certo ao conectar com o banco!", error);
}
};.env
Contém variáveis de ambiente para configurações sensíveis.
PORT=4000
MONGO_URL=mongodb://localhost:27017/hbnb
JWT_SECRET_KEY=your_base64_encoded_secret_keybackend/tmp/
Pasta para armazenamento temporário de imagens (não é um arquivo, mas criada para uso futuro).
Instruções para a Aula 8
- Configuração:
- Crie a estrutura de pastas: backend/domains/places, backend/domains/users, backend/utils, backend/config, backend/routes, backend/tmp.
- Instale as dependências: npm install express mongoose bcryptjs jsonwebtoken cors cookie-parser dotenv.
- Configure o arquivo .env com as variáveis PORT, MONGO_URL e JWT_SECRET_KEY (use uma chave codificada em Base64 para o JWT).
- Execute o servidor: node backend/index.js.
- Testes:
- Use o ReqBin para testar o endpoint POST /places:
- URL: http://localhost:4000/places
- Headers: Content-Type: application/json, Cookie: token=<seu_token_jwt>
- Body:
{ "title": "Casa na Praia", "city": "Florianópolis, Brasil", "photos": [], "description": "Casa confortável à beira-mar", "extras": "Churrasqueira", "perks": ["wifi", "estacionamento"], "price": 500, "checkin": "14:00", "checkout": "12:00", "guests": 4 } - Esperado: Resposta com o documento criado (newPlaceDoc).
- Teste o endpoint GET /users/profile para verificar autenticação.
- Use o ReqBin para testar o endpoint POST /places:
- Observações:
- O endpoint /places/upload/link será usado na Aula 9, mas já está implementado.
- O token JWT é obtido via POST /users (cadastro) ou POST /users/login.
Encerramento da Aula 8
Nesta aula implementamos o backend com modelos (Places, Users), rotas (users, places), a autenticação JWT (jwt.js) e a conexão com MongoDB (db.js). Também mostramos como realizar testes via ReqBin.
Aula 9 - Integração com Frontend e Upload de Imagens
Nesta aula vamos implementar o frontend para criar acomodações, integrar com o backend via Axios, e adicionar funcionalidade de upload de imagens por link, bem como a exibição de imagens e refatoração do backend.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Arquivos da Aula 9
frontend/src/components/NewPlace.jsx
Componente React para o formulário de criação de acomodações.
import { useState } from "react";
import Perks from "./Perks";
import axios from "axios";
import { Navigate } from "react-router-dom";
import { useUserContext } from "../contexts/UserContext.jsx";
import PhotoUploader from "./PhotoUploader.jsx";
const NewPlace = () => {
const { user } = useUserContext();
const [title, setTitle] = useState("");
const [city, setCity] = useState("");
const [photos, setPhotos] = useState([]);
const [perks, setPerks] = useState([]);
const [description, setDescription] = useState("");
const [extras, setExtras] = useState("");
const [price, setPrice] = useState("");
const [checkin, setCheckin] = useState("");
const [checkout, setCheckout] = useState("");
const [guests, setGuests] = useState("");
const [redirect, setRedirect] = useState(false);
const [photolink, setPhotoLink] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
if (
title &&
city &&
description &&
price &&
checkin &&
checkout &&
guests
) {
try {
const newPlace = await axios.post("/places", {
owner: user._id,
title,
city,
photos,
description,
extras,
perks,
price,
checkin,
checkout,
guests,
});
console.log(newPlace);
setRedirect(true);
} catch (error) {
console.error(JSON.stringify(error));
alert("Deu erro ao tentar criar um novo lugar");
}
} else {
alert("Preencha todas as informações antes de enviar");
}
};
if (redirect) return <Navigate to="/account/places" />;
return (
<form onSubmit={handleSubmit} className="flex w-full flex-col gap-6 px-8">
<div className="flex flex-col gap-1">
<label htmlFor="title" className="ml-2 text-2xl font-bold">
Título
</label>
<input
type="text"
placeholder="Digite o título do seu anúncio"
className="rounded-full border border-gray-300 px-4 py-2"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="city" className="ml-2 text-2xl font-bold">
Cidade e País
</label>
<input
type="text"
placeholder="Digite a cidade e país do seu anúncio"
className="rounded-full border border-gray-300 px-4 py-2"
id="city"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
</div>
<PhotoUploader {...{ photolink, setPhotoLink, setPhotos, photos }} />
<div className="flex flex-col gap-1">
<label htmlFor="description" className="ml-2 text-2xl font-bold">
Descrição
</label>
<textarea
placeholder="Digite a descrição do seu anúncio"
className="h-56 resize-none rounded-2xl border border-gray-300 px-4 py-2"
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="perks" className="ml-2 text-2xl font-bold">
Comodidades
</label>
<Perks {...{ perks, setPerks }} />
</div>
<div className="flex flex-col gap-1">
<label htmlFor="extras" className="ml-2 text-2xl font-bold">
Informações Extras
</label>
<textarea
placeholder="Digite a descrição do seu anúncio"
className="h-56 resize-none rounded-2xl border border-gray-300 px-4 py-2"
id="extras"
value={extras}
onChange={(e)}
=> setExtras(e.target.value)} />
</div>
<div className="flex flex-col gap-1">
<h2 className="ml-2 text-2xl font-bold">Restrições e Preço</h2>
<div className="grid grid-cols-[repeat(auto-fit,minmax(225px,1fr))] gap-6">
<div className="flex flex-col gap-2">
<label className="ml-2 text-xl font-bold" htmlFor="price">
Preço
</label>
<input
type="number"
placeholder="500"
className="rounded-full border border-gray-300 px-4 py-2"
id="price"
value={price}
onChange={(e) => setPrice(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="ml-2 text-xl font-bold" htmlFor="checkin">
Checkin
</label>
<input
type="text"
placeholder="16:00"
className="rounded-full border border-gray-300 px-4 py-2"
id="checkin"
value={checkin}
onChange={(e)}
=> setCheckin(e.target.value)} />
/>
</div>
<div className="flex flex-col gap-2">
<label className="ml-2 text-xl font-bold" htmlFor="checkout">
Checkout
</label>
<input
type="text"
placeholder="12:00"
className="rounded-full border border-gray-300 px-4 py-2"
id="Checkout"
value={Checkout}
onChange={(e) => setCheckout(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<label className="ml-2 text-xl font-bold" htmlFor="guests">
N° de convidados
</label>
<input
type="number"
placeholder="4"
className="rounded-full border border-gray-300 px-4 py-2"
id="guests"
value={guests}
onChange={(e) => setGuests(e.target.value)}
/>
</div>
</div>
</div>
<button className="hover:bg-primary-500 bg-primary-400 min-w-44 cursor-pointer rounded-full px-4 py-2 text-white transition">
Salvar informações
</button>
</form>
);
};
export default NewPlace;frontend/src/components/PhotoUploader.jsx
Componente para upload e exibição de imagens por link.
import axios from "axios';
import React from "react";
const PhotoUploader = ({ photolink, setPhotoLink, setPhotos, photos }) => {
const uploadByLink = async (e) => {
e.preventDefault();
if (photolink) {
const { data: filename } = await axios.post("/places/upload/link", {
link: photolink,
});
setPhotos((prevValue) => [...prevValue, filename]);
} else {
alert("Não existe nenhum link a ser enviado!");
}
return };
return (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1">
<label htmlFor="photos" className="ml-2 text-2xl font-bold">
Fotos
</label>
<div className="flex gap-2">
<div className="flex gap-2">
<input
type="text"
placeholder=""
type="text"
placeholder="Add a photo by link"
className="grow rounded-full border border-gray-300 px-4 py-4 py-2"
id="photolink"
value={photolink}
onChange={(e) => setPhotoLink(e.target.value)}
/>
<button
onClick={uploadByLink}
className="cursor-pointer rounded-full border
border rounded-full bg-gray-100 px-4 px-4 py-2
transition hover:bg-gray-200"
>
Upload photo
</button>
</button>
</div>
<div className="mt-2 grid grid-cols-5 gap-4">
{photos.map((photo) => (
<img
className="aspect-square rounded-2xl rounded-2xl object-cover"
src={`${axios}/${tmp}/${photo}`}
alt="Imagens do Lugar"
key={photo}
/>
))}
))}
<label
htmlFor="file"
<label for="file">
class="flex aspect-square items-center cursor-pointer items-center
justify-center gap-2
rounded-2xl border rounded2xl border
border-gray-300"
>
<input type="file" id="file" class="hidden">
<svg>
xmlns="http://www.w3.org/svg/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
Upload
</label>
</label>
</div>
</div>
);
};
export default PhotoUploader;frontend/src/context/UserContext.jsx
Contexto React para gerenciar o estado do usuário.
import axios from "axios";
import { createContext, useContext, useEffect, useState } from "react";
export const UserContext = createContext(null);
export const useUserContext = () => useContext(UserContext);
export const UserContextProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [ready, setReady] = useState(false);
useEffect(() => {
const axiosGet = async () => {
const { data } = await axios.get("/users/profile");
setUser(data);
setReady(true);
};
axiosGet();
}, []);
return (
<UserContext.Provider value={{ user, setUser, ready }}>
{children}
</UserContext.Provider>
);
};backend/utils/imageDownloader.js
Função utilitária para baixar imagens a partir de URLs.
import download from "image-downloader";
import mime from "mime-types";
export const downloadImage = async (link, destination) => {
const mimeType = mime.lookup(link);
const contentType = mime.contentType(mimeType);
const extension = mime.extension(contentType);
const filename = `${Date.now()}.${extension}`;
const fullPath = `${destination}${filename}`;
try {
const options = {
url: link,
dest: fullPath,
};
await download.image(options);
return filename;
} catch (error) {
console.error(error);
throw error;
}
};Instruções para a Aula 9
- Configuração:
- Crie a estrutura de pastas no frontend: frontend/src/components, frontend/src/contexts.
- Instale as dependências no backend: npm install image-downloader mime-types.
- Instale as dependências no frontend: npm install axios react-router-dom.
- Configure o Axios no frontend (por exemplo, em main.jsx):
import axios from 'axios'; axios.defaults.baseURL = 'http://localhost:4000'; axios.defaults.withCredentials = true; - Crie o componente Perks.jsx (não fornecido, mas assume-se que existe com checkboxes para selecionar comodidades como Wi-Fi, estacionamento, etc.).
- Execute o backend (node backend/index.js) e o frontend (npm run dev).
- Testes:
- Acesse o frontend (http://localhost:5174) e faça login/cadastro para obter um token JWT.
- Navegue até a página de criação de lugares (exemplo: /account/places/new).
- Preencha o formulário com:
- Título: "Casa na Praia"
- Cidade: "Florianópolis, Brasil"
- Descrição: "Casa à beira-mar"
- Preço: 500
- Check-in: "14:00"
- Check-out: "12:00"
- Convidados: 4
- Fotos: Insira um link de imagem (ex.: https://example.com/image.jpg) e clique em "Adicionar foto".
- Envie o formulário e verifique o redirecionamento para /account/places.
- Teste o upload de imagens diretamente no ReqBin:
- URL: http://localhost:4000/places/upload/link
- Headers: Content-Type: application/json, Cookie: token=<seu_token_jwt>
- Body:
{ "link": "https://example.com/image.jpg" } - Esperado: Nome do arquivo gerado (ex.: 1697051234567.jpg).
Encerramento da Aula 9
Nesta aula adicionamos o frontend com NewPlace.jsx (formulário), PhotoUploader.jsx (upload de imagens), UserContext.jsx (estado de usuário) e imageDownloader.js (download de imagens no backend). Também integramos frontend e backend via Axios.
Aula 10 - Avançando na Integração do Projeto MERN com AWS S3
Bem-vindos à Aula 10 do nosso projeto MERN (MongoDB, Express, React, Node.js), onde estamos construindo o HBNB!
Aqui vamos avançar na integração com o AWS S3 para upload de múltiplas imagens diretamente do frontend, além de otimizar nossa lógica de sincronização e refatorar o código para melhor organização. Vamos também corrigir alguns bugs que surgiram na aula anterior e garantir que tudo funcione perfeitamente.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Objetivos da Aula
- Implementar o upload de múltiplas imagens diretamente do frontend para o AWS S3.
- Refatorar o controlador para centralizar a lógica de upload e download.
- Corrigir problemas de sincronização no envio de múltiplos arquivos.
- Testar e garantir que as imagens sejam exibidas corretamente no frontend.
- Fazer o commit das alterações no GitHub.
Passo a Passo
1. Revisão da Aula 9
Na última aula, configuramos o AWS S3, criamos um bucket (hbnbyoutube), geramos credenciais de acesso, e implementamos o upload de imagens via link. Criamos um controlador (controller.js) para gerenciar o envio de arquivos ao S3 e usamos o middleware multer para processar uploads de arquivos. No entanto, encontramos problemas com a sincronização de múltiplos uploads e extensões de arquivos. Hoje, vamos resolver isso e avançar.
2. Refatorando o Controlador
Para manter o código organizado, vamos centralizar todas as funções relacionadas a uploads e downloads no arquivo controller.js. Vamos mover a lógica de downloadImage e ajustar a função uploadImage para suportar múltiplos arquivos.
// backend/controller.js
import { config } from 'dotenv';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import fs from 'fs';
import path from 'path';
import multer from 'multer';
config();
const s3AccessKey = process.env.S3_ACCESS_KEY;
const s3SecretKey = process.env.S3_SECRET_KEY;
const s3Bucket = process.env.BUCKET;
const region = 'us-east-2';
const s3Client = new S3Client({
region,
credentials: {
accessKeyId: s3AccessKey,
secretAccessKey: s3SecretKey,
},
});
// Configuração do Multer
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, path.join(process.cwd(), 'tmp'));
},
filename: (req, file, cb) => {
const extension = path.extname(file.originalname);
const uniqueSuffix = `${Date.now()}${extension}`;
cb(null, uniqueSuffix);
},
});
export const uploadImage = multer({ storage }).array('files', 12);
// Função para baixar imagem de um link
export async function downloadImage(link) {
const response = await fetch(link);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const fileName = `${Date.now()}.jpg`;
const fullPath = path.join(process.cwd(), 'tmp', fileName);
fs.writeFileSync(fullPath, buffer);
return { fileName, fullPath, mimeType: response.headers.get('content-type') };
}
// Função para enviar arquivo ao S3
export async function sendToS3({ fileName, path: filePath, mimeType }) {
try {
const fileContent = fs.readFileSync(filePath);
const command = new PutObjectCommand({
Bucket: s3Bucket,
Key: fileName,
Body: fileContent,
ContentType: mimeType,
ACL: 'public-read',
});
await s3Client.send(command);
const url = `https://${s3Bucket}.s3.${region}.amazonaws.com/${fileName}`;
return url;
} catch (error) {
console.error('Erro ao enviar para o S3:', error);
throw error;
}
}Mudanças:
- Movemos downloadImage para o controlador.
- Ajustamos o multer para usar storage com extensões corretas.
- Garantimos que sendToS3 retorne a URL pública do arquivo no S3.
3. Ajustando as Rotas
Vamos atualizar o arquivo de rotas (routes/places.js) para usar o uploadImage e processar múltiplos arquivos.
// backend/routes/places.js
import express from 'express';
import { downloadImage, sendToS3, uploadImage } from '../controller.js';
const router = express.Router();
router.post('/upload-by-link', async (req, res) => {
try {
const { link } = req.body;
const { fileName, fullPath, mimeType } = await downloadImage(link);
const fileUrl = await sendToS3({ fileName, path: fullPath, mimeType });
res.json({ fileUrl });
} catch (error) {
res.status(500).json({ error: 'Erro ao processar o upload por link' });
}
});
router.post('/upload', uploadImage, async (req, res) => {
try {
const files = req.files;
const fileUrls = [];
const filesPromise = new Promise((resolve, reject) => {
let processed = 0;
files.forEach(async (file, index) => {
try {
const { originalname, path, mimetype } = file;
const fileName = `${Date.now()}-${index}${path.extname(originalname)}`;
const fileUrl = await sendToS3({
fileName,
path,
mimeType: mimetype,
});
fileUrls.push(fileUrl);
processed++;
if (processed === files.length) {
resolve(fileUrls);
}
} catch (error) {
reject(error);
}
});
});
const urls = await filesPromise;
res.json({ urls });
} catch (error) {
console.error('Erro ao processar upload:', error);
res.status(500).json({ error: 'Erro ao processar o upload' });
}
});
export default router;Mudanças:
- Usamos uploadImage como middleware na rota /upload.
- Simplificamos a lógica de sincronização, usando uma Promise para esperar todos os uploads.
- Cada arquivo é processado individualmente, e as URLs são coletadas em fileUrls.
- Retornamos um array de URLs como resposta.
4. Atualizando o Frontend
No frontend, vamos ajustar o componente PhotoUploader.js para enviar múltiplos arquivos e atualizar o estado com as URLs retornadas.
// frontend/src/PhotoUploader.js
import { useState } from 'react';
import axios from 'axios';
export default function PhotoUploader() {
const [photos, setPhotos] = useState([]);
async function uploadByLink(ev) {
ev.preventDefault();
const link = ev.target[0].value;
try {
const { data } = await axios.post('/upload-by-link', { link });
setPhotos((prev) => [...prev, data.fileUrl]);
ev.target.reset();
} catch (error) {
alert(`Erro ao fazer upload por link: ${JSON.stringify(error)}`);
}
}
async function upload(ev) {
ev.preventDefault();
const files = ev.target.files;
const formData = new FormData();
Array.from(files).forEach((file) => {
formData.append('files', file);
});
try {
const { data } = await axios.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setPhotos((prev) => [...prev, ...data.urls]);
ev.target.reset();
} catch (error) {
alert(`Erro ao fazer upload: ${JSON.stringify(error)}`);
}
}
return (
<div>
<form onSubmit={uploadByLink}>
<input type="text" placeholder="Cole o link da imagem" />
<button type="submit">Adicionar por Link</button>
</form>
<input type="file" multiple onChange={upload} />
<div>
{photos.map((photo, index) => (
<img key={index} src={photo} alt={`Uploaded ${index}`} style={{ width: '100px' }} />
))}
</div>
</div>
);
}Mudanças:
- Adicionamos suporte para múltiplos arquivos com multiple no input.
- Usamos FormData para enviar arquivos ao backend.
- Atualizamos o estado photos com um spread das URLs retornadas.
- Adicionamos tratamento de erros com try/catch.
5. Testando o Upload
- Limpar o bucket S3:
- Acesse o AWS S3, vá ao bucket hbnbyoutube, e exclua todos os objetos para começar do zero.
- Iniciar o servidor:
cd backend npm startcd frontend npm start - Testar upload por link:
- Cole um link de imagem no input e clique em "Adicionar por Link".
- Verifique se a imagem aparece no frontend e no S3.
- Testar upload de arquivos:
- Selecione duas imagens no input de arquivo.
- Verifique se ambas aparecem no frontend e no S3 com URLs únicas.
Exemplo de saída no S3:
- Arquivos como 1667234567890-0.jpg e 1667234567891-1.jpg devem aparecer no bucket.
- URLs públicas: https://hbnbyoutube.s3.us-east-2.amazonaws.com/1667234567890-0.jpg.
6. Correção de Bugs
- Problema de sincronização: Ajustamos a lógica de Promise para garantir que todos os arquivos sejam enviados antes de retornar a resposta.
- Extensões de arquivo: Usamos path.extname no multer e no sendToS3 para manter as extensões corretas.
- Chaves únicas: Adicionamos um sufixo único com Date.now() e índice para evitar conflitos.
Encerramento da Aula 10
Conseguimos implementar o upload de múltiplas imagens, refatorar o controlador, e corrigir problemas de sincronização. As imagens agora são enviadas ao S3 e exibidas corretamente no frontend. Parabéns por chegar até aqui!
E não esqueça de fazer o commit!
git add .
git commit -m "Fim da Aula 10 - Upload múltiplo para AWS S3"
git push origin mainAula 11 - Cadastro e Listagem de Acomodações no Projeto MERN com AWS S3
Bem-vindos à Aula 11 do nosso projeto MERN (MongoDB, Express, React, Node.js), onde estamos construindo uma réplica do Airbnb chamada HBNB!
Nesta aula vamos avançar na criação e listagem de acomodações, implementar a funcionalidade de atualizar lugares existentes, corrigir bugs (como o erro de invalid signature), e exibir as acomodações na página inicial. Vamos também adicionar botões para favoritar e remover imagens no frontend.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Objetivos da Aula
- Implementar o cadastro de novas acomodações com upload de imagens.
- Criar uma rota para listar acomodações por usuário autenticado.
- Adicionar funcionalidade de atualização de acomodações existentes (método PUT).
- Exibir todas as acomodações na página inicial.
- Adicionar botões para promover e deletar imagens no componente de upload.
- Corrigir o erro de invalid signature no JWT.
- Fazer o commit das alterações no GitHub.
Passo a Passo
1. Revisão da Aula 10
Na última aula, implementamos o upload de múltiplas imagens para o AWS S3 diretamente do frontend, refatoramos o controlador para centralizar a lógica de upload/download, e corrigimos problemas de sincronização. Hoje, vamos usar essas imagens para criar e atualizar acomodações, listar as acomodações de um usuário, e exibi-las na página inicial.
2. Configurando o Banco de Dados
Vamos garantir que o MongoDB esteja limpo e configurado. Acesse o MongoDB, vá para a coleção places, e delete os documentos existentes para começar do zero. Cada documento de place terá campos como title, city, country, photos (array de URLs do S3), description, perks, extraInfo, price, checkIn, checkOut, maxGuests, e owner (ID do usuário).
3. Criando a Rota para Listar Acomodações por Usuário
Vamos criar uma rota GET em /places/owner para listar todas as acomodações de um usuário autenticado, usando JWT para identificar o usuário.
// backend/routes/places.js
import express from 'express';
import jwt from 'jsonwebtoken';
import Place from '../models/Place.js';
const router = express.Router();
router.get('/owner', async (req, res) => {
try {
const { token } = req.cookies;
if (!token) return res.status(401).json({ error: 'Não autenticado' });
const userInfo = jwt.verify(token, process.env.JWT_SECRET);
const { id } = userInfo;
const placeDocs = await Place.find({ owner: id });
res.json(placeDocs);
} catch (error) {
console.error('Erro ao buscar acomodações:', error);
res.status(500).json({ error: 'Erro ao buscar acomodações' });
}
});
export default router;Explicação:
- Extraímos o token dos cookies.
- Verificamos o token com jwt.verify para obter o id do usuário.
- Buscamos todos os documentos em Place onde owner é igual ao id.
- Retornamos a lista de acomodações.
4. Criando a Rota para Listar Todas as Acomodações
Para a página inicial, criamos uma rota GET em /places que retorna todas as acomodações.
// backend/routes/places.js
router.get('/', async (req, res) => {
try {
const placeDocs = await Place.find({});
res.json(placeDocs);
} catch (error) {
console.error('Erro ao buscar acomodações:', error);
res.status(500).json({ error: 'Erro ao buscar acomodações' });
}
});Explicação:
- Usamos Place.find({}) para buscar todos os documentos na coleção places.
- Retornamos a lista completa.
5. Criando a Rota para Buscar uma Acomodação Específica
Adicionamos uma rota GET em /places/:id para buscar uma acomodação específica pelo ID.
// backend/routes/places.js
router.get('/:id', async (req, res) => {
try {
const { id } = req.params;
const placeDoc = await Place.findById(id);
if (!placeDoc) return res.status(404).json({ error: 'Acomodação não encontrada' });
res.json(placeDoc);
} catch (error) {
console.error('Erro ao buscar acomodação:', error);
res.status(500).json({ error: 'Erro ao buscar acomodação' });
}
});Explicação:
- Extraímos o id dos parâmetros da URL.
- Usamos findById para buscar a acomodação.
- Retornamos o documento ou um erro 404 se não encontrado.
6. Atualizando a Rota para Criar/Atualizar Acomodações
Modificamos a rota POST /places para criar novas acomodações e adicionamos uma rota PUT /places/:id para atualizar acomodações existentes.
// backend/routes/places.js
router.post('/', async (req, res) => {
try {
const { token } = req.cookies;
const userInfo = jwt.verify(token, process.env.JWT_SECRET);
const { id: owner } = userInfo;
const {
title, city, country, photos, description, perks,
extraInfo, price, checkIn, checkOut, maxGuests,
} = req.body;
const placeDoc = await Place.create({
owner, title, city, country, photos, description, perks,
extraInfo, price, checkIn, checkOut, maxGuests,
});
res.json(placeDoc);
} catch (error) {
console.error('Erro ao criar acomodação:', error);
res.status(500).json({ error: 'Erro ao criar acomodação' });
}
});
router.put('/:id', async (req, res) => {
try {
const { id } = req.params;
const {
title, city, country, photos, description, perks,
extraInfo, price, checkIn, checkOut, maxGuests,
} = req.body;
const updatedPlaceDoc = await Place.findByIdAndUpdate(
id,
{
title, city, country, photos, description, perks,
extraInfo, price, checkIn, checkOut, maxGuests,
},
{ new: true }
);
if (!updatedPlaceDoc) return res.status(404).json({ error: 'Acomodação não encontrada' });
res.json(updatedPlaceDoc);
} catch (error) {
console.error('Erro ao atualizar acomodação:', error);
res.status(500).json({ error: 'Erro ao atualizar acomodação' });
}
});Explicação:
- POST: Cria uma nova acomodação, associando o owner ao ID do usuário autenticado.
- PUT: Atualiza uma acomodação existente pelo id, mantendo o owner inalterado.
- O parâmetro { new: true } retorna o documento atualizado.
7. Corrigindo o Erro de Invalid Signature
O erro invalid signature ocorreu devido a uma má configuração da chave JWT. Garantimos que a chave seja uma string fixa e consistente.
// backend/index.js
import dotenv from 'dotenv';
dotenv.config();
const JWT_SECRET = process.env.JWT_SECRET || 'sua-chave-secreta-aqui';Explicação:
- Usamos uma chave fixa no .env ou um fallback para evitar problemas.
- Certifique-se de que JWT_SECRET seja o mesmo ao assinar e verificar o token.
8. Atualizando o Frontend - Listagem de Acomodações
No componente AccountPlaces, usamos useEffect para buscar as acomodações do usuário e exibi-las.
// frontend/src/pages/AccountPlaces.js
import { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom';
import axios from 'axios';
import PhotoUploader from '../components/PhotoUploader';
export default function AccountPlaces() {
const { action, id } = useParams();
const [places, setPlaces] = useState([]);
useEffect(() => {
axios.get('/places/owner').then(({ data }) => {
setPlaces(data);
}).catch(error => {
console.error('Erro ao buscar acomodações:', error);
});
}, [action]);
if (action === 'new' || id) {
return <PhotoUploader />;
}
return (
<div className="flex flex-col gap-8 items-center">
<Link to={'/account/places/new'} className="bg-primary text-white px-4 py-2 rounded">Adicionar Novo Lugar</Link>
{places.map(place => (
<Link to={`/account/places/${place._id}`} key={place._id} className="flex gap-4 bg-gray-100 p-4 rounded-2xl cursor-pointer">
<img src={place.photos[0]} alt="Acomodação" className="w-56 h-56 object-cover rounded-2xl" />
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-medium">{place.title}</h2>
<p>{place.description}</p>
</div>
</Link>
))}
</div>
);
}Explicação:
- Usamos useEffect para buscar acomodações do endpoint /places/owner.
- Monitoramos action para atualizar a lista após criar uma nova acomodação.
- Exibimos um botão para criar um novo lugar e uma lista de acomodações com links para edição.
9. Atualizando o Componente PhotoUploader
Adicionamos botões para promover e deletar imagens no componente PhotoUploader.
// frontend/src/PhotoUploader.js
import { useState, useEffect } from 'react';
import axios from 'axios';
import { StarIcon, TrashIcon } from '@heroicons/react/24/outline';
export default function PhotoUploader() {
const { id } = useParams();
const [photos, setPhotos] = useState([]);
const [title, setTitle] = useState('');
const [city, setCity] = useState('');
const [country, setCountry] = useState('');
const [description, setDescription] = useState('');
const [perks, setPerks] = useState([]);
const [extraInfo, setExtraInfo] = useState('');
const [price, setPrice] = useState('');
const [checkIn, setCheckIn] = useState('');
const [checkOut, setCheckOut] = useState('');
const [maxGuests, setMaxGuests] = useState('');
const [redirect, setRedirect] = useState(false);
useEffect(() => {
if (id) {
axios.get(`/places/${id}`).then(({ data }) => {
setTitle(data.title);
setCity(data.city);
setCountry(data.country);
setPhotos(data.photos);
setDescription(data.description);
setPerks(data.perks);
setExtraInfo(data.extraInfo);
setPrice(data.price);
setCheckIn(data.checkIn);
setCheckOut(data.checkOut);
setMaxGuests(data.maxGuests);
});
}
}, [id]);
async function uploadByLink(ev) {
ev.preventDefault();
const link = ev.target[0].value;
try {
const { data } = await axios.post('/upload-by-link', { link });
setPhotos(prev => [...prev, data.fileUrl]);
ev.target.reset();
} catch (error) {
alert(`Erro ao fazer upload por link: ${JSON.stringify(error)}`);
}
}
async function upload(ev) {
ev.preventDefault();
const files = ev.target.files;
const formData = new FormData();
Array.from(files).forEach(file => formData.append('files', file));
try {
const { data } = await axios.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setPhotos(prev => [...prev, ...data.urls]);
ev.target.reset();
} catch (error) {
alert(`Erro ao fazer upload: ${JSON.stringify(error)}`);
}
}
function deletePhoto(fileUrl) {
const newPhotos = photos.filter(photo => photo !== fileUrl);
setPhotos(newPhotos);
}
function promotePhoto(fileUrl) {
const newPhotos = [fileUrl, ...photos.filter(photo => photo !== fileUrl)];
setPhotos(newPhotos);
}
async function handleSubmit(ev) {
ev.preventDefault();
if (photos.length === 0) return alert('Adicione pelo menos uma foto');
const placeData = {
title, city, country, photos, description, perks,
extraInfo, price, checkIn, checkOut, maxGuests,
};
try {
if (id) {
await axios.put(`/places/${id}`, placeData);
} else {
await axios.post('/places', placeData);
}
setRedirect(true);
} catch (error) {
console.error('Erro ao salvar acomodação:', error);
alert(id ? 'Erro ao atualizar acomodação' : 'Erro ao criar acomodação');
}
}
if (redirect) {
return <Navigate to="/account/places" />;
}
return (
<form onSubmit={handleSubmit}>
<input type="text" value={title} onChange={ev => setTitle(ev.target.value)} placeholder="Título" />
<input type="text" value={city} onChange={ev => setCity(ev.target.value)} placeholder="Cidade" />
<input type="text" value={country} onChange={ev => setCountry(ev.target.value)} placeholder="País" />
<form onSubmit={uploadByLink}>
<input type="text" placeholder="Cole o link da imagem" />
<button type="submit">Adicionar por Link</button>
</form>
<input type="file" multiple onChange={upload} />
<div className="flex flex-col gap-8">
{photos.map((photo, index) => (
<div key={index} className="relative">
<img src={photo} alt={`Uploaded ${index}`} className="w-56 h-56 object-cover rounded-2xl" />
<div className="absolute bottom-2 right-2 flex gap-1">
<div
onClick={() => promotePhoto(photo)}
className="bg-gray-100 opacity-75 rounded-full p-2 cursor-pointer hover:bg-primary hover:text-white transition"
>
<StarIcon className="w-6 h-6" />
</div>
<div
onClick={() => deletePhoto(photo)}
className="bg-gray-100 opacity-75 rounded-full p-2 cursor-pointer hover:bg-primary hover:text-white transition"
>
<TrashIcon className="w-6 h-6" />
</div>
</div>
</div>
))}
</div>
<div>
<label>Comodidades</label>
<input type="checkbox" checked={perks.includes('wifi')} onChange={() => handlePerkChange('wifi')} /> Wi-Fi
<input type="checkbox" checked={perks.includes('parking')} onChange={() => handlePerkChange('parking')} /> Estacionamento
<input type="checkbox" checked={perks.includes('radio')} onChange={() => handlePerkChange('radio')} /> Rádio
<input type="checkbox" checked={perks.includes('pets')} onChange={() => handlePerkChange('pets')} /> Pets
<input type="checkbox" checked={perks.includes('entrance')} onChange={() => handlePerkChange('entrance')} /> Entrada Privada
</div>
<textarea value={description} onChange={ev => setDescription(ev.target.value)} placeholder="Descrição" />
<textarea value={extraInfo} onChange={ev => setExtraInfo(ev.target.value)} placeholder="Informações Extras" />
<input type="number" value={price} onChange={ev => setPrice(ev.target.value)} placeholder="Preço" />
<input type="number" value={checkIn} onChange={ev => setCheckIn(ev.target.value)} placeholder="Check-in" />
<input type="number" value={checkOut} onChange={ev => setCheckOut(ev.target.value)} placeholder="Check-out" />
<input type="number" value={maxGuests} onChange={ev => setMaxGuests(ev.target.value)} placeholder="Máximo de Hóspedes" />
<button type="submit">Salvar Informações</button>
</form>
);
function handlePerkChange(perk) {
setPerks(prev => prev.includes(perk) ? prev.filter(p => p !== perk) : [...prev, perk]);
}
}Explicação:
- Adicionamos estados para todos os campos do formulário.
- Usamos useEffect para carregar os dados de uma acomodação existente se id estiver presente.
- Implementamos deletePhoto para remover uma imagem do estado photos.
- Implementamos promotePhoto para mover uma imagem para o início da lista.
- Estilizamos os botões com HeroIcons (estrela e lixeira) usando Tailwind CSS.
- Ajustamos os checkboxes de perks para refletir o estado inicial.
10. Exibindo Acomodações na Página Inicial
Na página Home, buscamos todas as acomodações e as exibimos.
// frontend/src/pages/Home.js
import { useState, useEffect } from 'react';
import axios from 'axios';
export default function Home() {
const [places, setPlaces] = useState([]);
useEffect(() => {
axios.get('/places').then(({ data }) => {
setPlaces(data);
}).catch(error => {
console.error('Erro ao buscar acomodações:', error);
});
}, []);
return (
<div className="flex flex-col gap-8">
{places.map(place => (
<div key={place._id} className="flex gap-4 bg-gray-100 p-4 rounded-2xl">
<img src={place.photos[0]} alt={place.title} className="w-56 h-56 object-cover rounded-2xl" />
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-medium">{place.title}</h2>
<p>{place.description}</p>
<p>R${place.price}/noite</p>
</div>
</div>
))}
</div>
);
}Explicação:
- Usamos useEffect para buscar todas as acomodações do endpoint /places.
- Mapeamos os places para exibir cada acomodação com sua primeira foto, título, descrição e preço.
11. Configurando Rotas Dinâmicas no Frontend
Ajustamos as rotas no App.js para suportar a edição de acomodações.
// frontend/src/App.js
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import AccountPlaces from './pages/AccountPlaces';
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/account/places" element={<AccountPlaces />} />
<Route path="/account/places/:action" element={<AccountPlaces />} />
<Route path="/account/places/:id" element={<AccountPlaces />} />
</Routes>
);
}Explicação:
- Adicionamos a rota /account/places/:id para carregar uma acomodação específica.
12. Testando a Funcionalidade
- Limpar o MongoDB:
- Acesse a coleção places e delete os documentos existentes.
- Iniciar os servidores:
cd backend npm startcd frontend npm start - Criar uma acomodação:
- Faça login com teste1@teste1.com e senha teste1.
- Vá para /account/places, clique em "Adicionar Novo Lugar".
- Preencha o formulário (título, cidade, país, fotos via link ou upload, descrição, perks, etc.) e salve.
- Verifique se a acomodação aparece em /account/places e no MongoDB.
- Editar uma acomodação:
- Clique na acomodação, modifique os dados (ex.: adicione uma foto, promova outra, altere o título).
- Salve e verifique se as alterações foram refletidas.
- Testar a página inicial:
- Acesse / e confirme que todas as acomodações são exibidas.
- Testar com outro usuário:
- Faça login com teste2@teste2.com e senha teste2.
- Crie outra acomodação e verifique que ela não aparece na lista do primeiro usuário.
13. Correção de Bugs
- Erro de invalid signature: Corrigido ao usar uma chave JWT consistente.
- Erro de useEffect não atualizando: Adicionamos action como dependência no useEffect de AccountPlaces.
- Erro de chave no mapeamento: Adicionamos key={place._id} nos componentes mapeados.
- Erro de perks não exibidos: Ajustamos os checkboxes para usar perks.includes.
Encerramento da Aula 11
Implementamos o cadastro, listagem e atualização de acomodações, corrigimos bugs, e exibimos as acomodações na página inicial. Parabéns por chegar até aqui!
git add .
git commit -m "Fim da Aula 11 - Cadastro e listagem de acomodações"
git push origin mainAula 12 - Tela de Detalhes da Acomodação e Funcionalidades de Reserva no Projeto MERN
Nesta aula, continuamos o desenvolvimento do projeto HBNB, uma réplica do Airbnb usando a stack MERN (MongoDB, Express, React, Node.js). O foco é construir a tela de detalhes de uma acomodação específica, implementar a funcionalidade de reserva com validação de login, exibir diferenciais (perks) de forma padronizada, e garantir responsividade para dispositivos móveis e desktop. Também preparamos o frontend para a lógica de reservas, que será concluída na próxima aula.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Objetivos da Aula
- Criar a tela de detalhes de uma acomodação com título, localização, grade de imagens, descrição, informações extras, e formulário de reserva.
- Implementar um overlay para visualização ampliada de imagens com rolagem.
- Exibir diferenciais (perks) usando um componente reutilizável.
- Garantir responsividade com Tailwind CSS para dispositivos móveis e desktop.
- Configurar a lógica de reserva com validação de login e inputs de check-in, check-out e número de convidados.
- Fazer o commit das alterações no GitHub.
Passo a Passo
1. Revisão da Aula 11
Na última aula, implementamos o cadastro, listagem e atualização de acomodações, corrigimos o erro de invalid signature no JWT, e exibimos as acomodações na página inicial. Hoje, vamos construir a tela de detalhes de uma acomodação acessada por /place/:id e preparar a funcionalidade de reserva.
2. Configurando a Rota no Frontend
Ajustamos o App.js para incluir a rota /place/:id que renderiza a página de detalhes.
// frontend/src/App.js
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import AccountPlaces from './pages/AccountPlaces';
import Place from './pages/Place';
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/account/places" element={<AccountPlaces />} />
<Route path="/account/places/:action" element={<AccountPlaces />} />
<Route path="/account/places/:id" element={<AccountPlaces />} />
<Route path="/place/:id" element={<Place />} />
</Routes>
);
}Explicação:
- Adicionamos a rota /place/:id para renderizar o componente Place.
3. Criando o Componente Place
Criamos o componente Place.js para exibir os detalhes de uma acomodação específica, usando useEffect para buscar os dados pelo ID.
// frontend/src/pages/Place.js
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import axios from 'axios';
import { MapPinIcon, QrCodeIcon } from '@heroicons/react/24/outline';
import Perk from '../components/Perk';
export default function Place() {
const { id } = useParams();
const [place, setPlace] = useState(null);
const [overlay, setOverlay] = useState(false);
useEffect(() => {
axios.get(`/places/${id}`).then(({ data }) => {
setPlace(data);
}).catch(error => {
console.error('Erro ao buscar acomodação:', error);
});
}, [id]);
useEffect(() => {
document.body.style.overflow = overlay ? 'hidden' : '';
return () => {
document.body.style.overflow = '';
};
}, [overlay]);
if (!place) return null;
return (
<section className="p-8 sm:p-4 flex flex-col gap-6 sm:gap-1">
<div>
<h1 className="text-3xl sm:text-xl font-bold">{place.title}</h1>
<div className="flex gap-2 items-center">
<MapPinIcon className="w-6 h-6" />
<p>{place.city}, {place.country}</p>
</div>
</div>
<div className="relative grid grid-cols-[2fr_1fr] sm:grid-cols-1 grid-rows-2 gap-4 sm:gap-2 aspect-[3/2] rounded-2xl overflow-hidden">
{place.photos.filter((_, index) => index < 0).map((photo, index) => (
<img
key={index}
src={photo}
alt={place.title ${index}`}
className={`w-full h-full object-${index} === 0 ? 'row-span-2' : ''} aspect-square rounded-2xl hover:opacity-80 transition cursor-pointer ${index} === 0 ? 'object-cover' : 'object-center'}}`}
onClick={() => setOverlay(true)}
/>
))}
<div className="absolute right-2 bottom-4 flex gap-4 gap-2 bg-white border border-black rounded-2xl py-2 px-4">
<QrCodeIcon className="h-6 w-6" />
<p className="text-sm">Mostrar mais imagens</p>
<button onClick={() => setOverlay(true)} className="hidden">Mostrar</button>
</div>
</div>
{overlay && (
<div className={`fixed inset-0 bg-black bg1000-0 flex flex-col gap-2 ${overlay ? 'flex' : 'hidden'}}`}>
<button
className="absolute top-2 right-4 right-2 bg-white text-black rounded-full w-8 h-8 flex items-center justify-center font-bold hover:scale-105 transition cursor-pointer"
onClick={() => setOverlay(false);
}}
>
X
</button>
<div className="mx-auto p-4 grid grid-cols-2 sm:grid-cols-1 gap-4 gap-2 overflow-y-auto">
{place.photos.map((photo, index)) => (
<img
key={photo}
src={photo}
className="w-full aspect-square"
/>
))}
</div>
</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-1 gap-8 sm:6 gap-4">
<div className="flex flex-col gap-4 gap-2">
<div>
<h3 className="text-2xl sm:text-xl font-semibold">Descrição</h3>
<p>{place.description}</p>
</div>
<div className="flex flex-col gap-2">
<h4 className="text-2xl sm:text-xl font-semibold">Horários e Restrições</h3>
<div className="flex flex-col gap-2">
<p><strong>Check-in:</strong> {place.checkIn}</p>
<p><strong>Check-out:</strong> {place.checkOut}</p>
<p><strong>Máximo de Convidados:</strong> {place.maxGuests}</p>
</div>
</div>
<div>
<h4 className="text-2xl sm:text-xl font-semibold">Informações Extras</h4>
<div className="flex flex-col gap-2 p-6 bg-gray-100 rounded-2xl">
<p>{place.extraInfo}</p>
</div>
<div>
<h4 className="text-2xl sm:text-xl font-semibold">Diferenciais</h3>
<div className="flex flex-col gap-2">
{place.perks.map((perk) => (
<Perk key={perk} perk={perk} />}
))}
</div>
</div>
</div>
</div>
<div className="justify-self-center sm:order-1 order-2 sm:order-0">
<div className="border border-gray-200 rounded-2xl p-4 py-3">
<h3 className="text-2xl sm:text-xl font-semibold">R$ {place.price} por noite</h3>
<form onSubmit={handleBooking}>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-2">
<div className="border border-gray-300 rounded-tl-2xl rounded-bl-2xl sm:rounded-tr-2xl sm:rounded-br-0 p-4 py-2 sm:border-l-0">
<p className="font-bold text-center">Check-in</p>
<input
type="date"
value={checkIn}
onChange={ev => setCheckIn(ev.target.value)}
className="w-full"
/>
</div>
<div className="border border-gray-300 rounded-tr-2xl rounded-br-2xl sm:rounded-tl-0 sm:rounded-bl-0 sm:border-t-0 p-4 py-2">
<p className="font-bold text-center">Check-out</p>
<input
type="date"
value={checkOut}
onChange={ev => setCheckOut(ev.target.value)}
className="w-full"
/>
</div>
</div>
<div className="border border-gray-300 rounded-2xl p-4 py-2 mt-2 flex flex-col gap-2">
<p className="font-bold text-center">Número de Convidados</p>
<input
type="number"
value={guests}
onChange={ev => setGuests(ev.target.value)}
placeholder="2"
className="w-auto"
/>
</div>
{user ? (
<button type="submit" className="bg-primary text-white rounded-2xl p-2 mt-2 text-center">
Reservar
</button>
) : (
<Link to="/login" className="bg-primary text-white rounded-2xl p-2 mt-2 text-center block">
Faça seu login
</Link>
)}
</form>
</div>
</div>
</div>
</section>
);
function handleBooking(ev) {
ev.preventDefault();
if (!checkIn || !checkOut || !guests) {
alert('Preencha todas as informações antes de fazer uma reserva');
return;
}
console.log('Fez uma reserva');
}
}Explicação:
- Estrutura: A página é dividida em seções: título/localização, grade de imagens, descrição/horários/informações extras/diferenciais, e formulário de reserva.
- Grade de Imagens: Usamos grid-cols-[2fr_1fr] grid-rows-2 para criar uma grade com uma imagem grande (2 linhas) e duas menores. Um botão "Mostrar mais imagens" exibe todas as fotos em um overlay.
- Overlay: Um estado overlay controla a exibição de um modal fixo com grid-cols-2 (ou grid-cols-1 em mobile) e rolagem vertical (overflow-y-auto).
- Formulário de Reserva: Inclui inputs para check-in, check-out e número de convidados. Se o usuário não estiver logado, exibe um link para /login. Caso contrário, permite submeter a reserva (lógica a ser implementada).
- Responsividade: Usamos classes Tailwind como sm:grid-cols-1, sm:text-xl, sm:flex-row para ajustar o layout em telas menores. A grade de imagens mostra apenas uma imagem em mobile, e o formulário de reserva alterna entre flex-col e flex-row.
- Efeitos: Adicionamos hover:opacity-80 nas imagens, hover:scale-105 no botão do overlay, e transition para suavizar as interações.
4. Criando o Componente Perk
Criamos um componente Perk.js para exibir os diferenciais (perks) de forma padronizada, reutilizando ícones e textos do formulário de cadastro.
// frontend/src/components/Perk.js
export default function Perk({ perk }) {
const perkInfo = {
wifi: {
svg: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.288 15.038a5.25 5.25 0 017.424 0M5.106 11.856c3.807-3.808 9.98-3.808 13.788 0M1.924 8.674c5.565-5.565 14.587-5.565 20.152 0M12.53 18.22l-.53.53-.53-.53a.75.75 0 011.06 0z" />
</svg>
),
text: 'Wi-Fi',
},
parking: {
svg: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 18.75a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h6m-9 0H3.375a1.125 1.125 0 01-1.125-1.125V14.25m17.25 4.5a1.5 1.5 0 01-3 0m3 0a1.5 1.5 0 00-3 0m3 0h1.125c.621 0 1.129-.504 1.09-1.124a17.902 17.902 0 00-3.213-9.193 2.056 2.056 0 00-1.58-.86H14.25M16.5 18.75h-2.25m0-11.177v-.958c0-.568-.422-1.048-.987-1.106a48.554 48.554 0 00-10.026 0 1.106 1.106 0 00-.987 1.106v7.635m12-6.677v6.677m0 4.5v-4.5m0 0h-12" />
</svg>
),
text: 'Estacionamento Gratuito',
},
radio: {
svg: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 7.5l16.5-4.125M12 6.75c-2.708 0-5.363.224-7.948.655C2.999 7.58 2.25 8.507 2.25 9.574v9.176A2.25 2.25 0 004.5 21h15a2.25 2.25 0 002.25-2.25V9.574c0-1.067-.75-1.994-1.802-2.169A48.329 48.329 0 0012 6.75zm-1.683 6.443l-.005.005-.006-.005.006-.005.005.005zm-.005 2.127l-.005-.006.005-.005.005.005-.005.005zm-2.116-.006l-.005.006-.006-.006.005-.005.006.005zm-.005-2.116l-.005-.005.006-.005-.006-.005.005.005zM9.255 10.5v.008h-.008V10.5h.008zm3.249 1.88l-.007.004-.003-.007.006-.003.004.006zm-1.38 5.126l-.003-.006.006-.004.004.007-.006.003zm.007-6.501l-.003.006-.007-.003.004-.007.006.004zm1.37 5.129l-.007-.004.004-.006.006.003-.004.007zm.504-1.877h-.008v-.007h.008v.007zM9.255 18v.008h-.008V18h.008zm-3.246-1.87l-.007.004L6 16.127l.006-.003.004.006zm1.366-5.119l-.004-.006.006-.004.004.007-.006.003zM7.38 17.5l-.003.006-.007-.003.004-.007.006.004zm-1.376-5.116L6 12.38l.003-.007.007.004-.004.007zm-.5 1.873h-.008v-.007h.008v.007zM17.25 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zm0 4.5a.75.75 0 110-1.75-.75 0 010-1.5z" />
</svg>
),
text: 'Rádio',
},
tv: {
svg: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 20.25h12m-7.5-3v3m3-3v3m-10.125-3h17.25c.621 0 1.125-.504 1.125-1.125V4.875c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504-1.125 1.125 1.125z" />
</svg>
),
text: 'TV',
},
pets: {
svg: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24 strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" stroke="round strokeLinejoin="round" d="M6.633 10.5c.806.447 1.806.447 1.5 2.25 2.25 0 1.773-.806 2.25-1.5 2.25-2.25-1.25-2.773-.806-2.25-2.25c-.447-.806.447-1.5 1.5-2.25 2.25 2.25 2.773.806 2.25 2.25zM12 10.5.367 10.5c.806-.447 1.5-2.25 2.5-2.773.806 2.25 2.25 2.25 2.773-.806 2.5-2.25-.447-.806-1.5-2.25-2.25-2.25zm-5.5-2.25c-.667 0-1.2.45-.667 1.2.267.347.632.347.9 0 .267-.347.632-.347.9 0 .533-.667 1.2-.45 1.667zm6.6 0c-.667 0-1.2.45-.667 1.2.267.347.632.347.9 0 .267-.347.632-.347.9 0 .533-.667 1.2-.45 1.667 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M12 12.8 12.75 12.75c0 .621-.504 1.125-1.125 1.125v-2.375a1.125 1.125 0 0-1-1.125-1.125h13.5a1.125 0 0 1-1.125 1.125 2.375v0c0 .621-.504 1.125-1.125 1.125-1.125h-2.25M8.25 12.75h7.5" />
</svg>
),
text: 'Pets permitido',
},
entrance: {
svg: (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24" strokeWidth={1.5} stroke="currentColor" className="w-6 h-6">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h0-3A2.25 2.25 0 008.25.5.25V9m0 0H3v10.5a2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 10.5V9.75m-6 0H9m4.5 4.5h-3" />
</svg>
),
text: 'Entrada Privada',
},
};
const { svg, text } = perkInfo[perk] || { svg: null, text: perk };
return (
<div className="flex gap-2 items-center">
{svg}
<p>{text}</p>
</div>
);
}Explicação:
- Criamos um objeto perkInfo que mapeia cada perk (ex.: wifi, parking) para seu SVG e texto correspondente.
- O componente recebe um perk como prop e retorna um div com o ícone e o texto, garantindo consistência com o formulário de cadastro.
- Se o perk não for encontrado, exibe apenas o texto como fallback.
5. Ajustando a Home para Links Dinâmicos
Modificamos o componente Home.js para que cada acomodação seja um link para sua página de detalhes (/place/:id).
// frontend/src/pages/Home.js
import { useState, useEffect } from 'react';
import axios from 'axios';
import { Link } from 'react-router-dom';
export default function Home() {
const [places, setPlaces] = useState([]);
useEffect(() => {
axios.get('/places').then(({ data }) => {
setPlaces(data);
}).catch(error => {
console.error('Erro ao buscar acomodações:', error);
});
}, []);
return (
<div className="flex flex-col gap-8 p-8">
{places.map(place => (
<Link key={place._id} to={`/place/${place._id}`} className="flex gap-4 bg-gray-100 p-4 rounded-2xl">
<img src={place.photos[0]} alt={place.title} className="w-56 h-56 object-cover rounded-2xl" />
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-medium">{place.title}</h2>
<p>{place.description}</p>
<p>R${place.price}/noite</p>
</div>
</Link>
))}
</div>
);
}Explicação:
- Substituímos a <div> por <Link> com to={/place/${place._id}} para redirecionar à página de detalhes.
- Erro Corrigido: Garantimos que cada elemento mapeado tenha uma key única (place._id).
6. Testando a Aplicação
- Limpar o MongoDB:
- Acesse a coleção places e delete os documentos existentes.
- Iniciar os servidores:
cd backend npm startcd ../frontend npm start - Criar uma acomodação:
- Faça login com teste1@teste1.com e senha teste1.
- Vá para /account/places, clique em "Adicionar Novo Lugar", preencha o formulário (título, cidade, fotos, perks, etc.) e salve.
- Verifique se a acomodação aparece na página inicial (/).
- Acessar a página de detalhes:
- Clique na acomodação na página inicial.
- Confirme que a tela exibe título, localização, imagens, descrição, informações extras, perks e o formulário de reserva.
- Clique em "mostrar mais imagens" para abrir o overlay e verificar a rolagem.
- Teste o formulário de reserva:
- Sem login, deve exibir "Faça seu login".
- Após login, deve validar check-in, check-out e número de convidados antes de enviar.
- Testar responsividade:
- Use o DevTools para testar em breakpoints (360px, 640px, 768px, 1024px).
- Verifique que a grade de imagens ajusta para uma coluna em mobile, os textos diminuem, e o formulário de reserva reorganiza em flex-row (sm) ou flex-col.
- Testar com outro usuário:
- Faça login com teste2@teste2.com e senha teste.
- Crie outra acomodação e acesse sua página de detalhes.
7. Correção de Bugs
- Erro de rolagem no overlay: Adicionamos overflow-y-auto no overlay e usamos useEffect para desabilitar o scroll da página (body.style.overflowY = 'hidden') quando o overlay está ativo.
- Erro de responsividade: Ajustamos a grade de imagens para sm:grid-cols-1 e usamos sm:order-1 para reposicionar a seção de reserva em mobile.
- Erro de bordas no formulário: Ajustamos as bordas dos inputs de check-in/check-out com sm:border-l-0 e sm:border-t-0 para evitar sobreposições em mobile.
- Erro de alinhamento dos perks: Adicionamos items-center no componente Perk e flex gap-2 na lista de perks.
Encerramento da Aula 12
Implementamos a tela de detalhes da acomodação com grade de imagens, overlay, formulário de reserva, exibição de perks, e responsividade. A base está pronta para a lógica de reservas na próxima aula, onde criaremos o endpoint /bookings e a página de listagem de reservas.
git add .
git commit -m "Aula 12: Tela de detalhes da acomodação e funcionalidades de reserva"
git push origin mainAula 13 - Implementação de Reservas no Projeto MERN
Nesta aula, continuamos o desenvolvimento do projeto HBNB, uma réplica do Airbnb usando a stack MERN (MongoDB, Express, React, Node.js). O foco é implementar a funcionalidade de reservas, incluindo a modelagem no MongoDB, criação de rotas de API, integração com o frontend, listagem de reservas do usuário, e exibição condicional de reservas na tela de detalhes da acomodação. Também garantimos responsividade e preparamos o projeto para deploy na próxima aula.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Objetivos da Aula
- Criar o modelo Booking no MongoDB.
- Implementar endpoints para criar e listar reservas.
- Integrar o formulário de reserva no frontend com cálculo dinâmico de noites.
- Criar a página de listagem de reservas do usuário (/account/bookings).
- Exibir reservas existentes na tela de detalhes da acomodação (/place/:id) e ocultar o formulário de reserva quando aplicável.
- Garantir responsividade com Tailwind CSS.
- Fazer o commit das alterações no GitHub.
Passo a Passo
1. Revisão da Aula 12
Na última aula, implementamos a tela de detalhes da acomodação (/place/:id) com grade de imagens, overlay, formulário de reserva, e validação de login. Hoje, vamos criar a lógica de reservas, incluindo o modelo Booking, endpoints, e a página de listagem de reservas.
2. Modelagem de Reservas com MongoDB
Criamos o modelo Booking para armazenar as reservas no MongoDB.
// backend/models/Booking.js
const mongoose = require('mongoose');
const bookingSchema = new mongoose.Schema({
place: { type: mongoose.Schema.Types.ObjectId, ref: 'Place', required: true },
user: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
checkIn: { type: Date, required: true },
checkOut: { type: Date, required: true },
guests: { type: Number, required: true },
price: { type: Number, required: true },
nights: { type: Number, required: true },
totalPrice: { type: Number, required: true },
});
module.exports = mongoose.model('Booking', bookingSchema);Explicação:
- Campos:
- place: ID da acomodação (referência ao modelo Place).
- user: ID do usuário que fez a reserva (referência ao modelo User).
- checkIn e checkOut: Datas de entrada e saída.
- guests: Número de convidados.
- price: Preço por noite.
- nights: Número de noites.
- totalPrice: Preço total (price * nights).
- Usamos ref para criar referências aos modelos Place e User, permitindo populações futuras.
3. Configuração das Rotas de API para Reservas
Criamos as rotas para criar e listar reservas.
// backend/routes/bookingsRoutes.js
const express = require('express');
const router = express.Router();
const Booking = require('../models/Booking');
const jwt = require('jsonwebtoken');
const connectDB = require('../config/db');
router.post('/', async (req, res) => {
await connectDB();
const { place, user, checkIn, checkOut, guests, price, nights, totalPrice } = req.body;
try {
const newBookingDoc = await Booking.create({
place,
user,
checkIn,
checkOut,
guests,
price,
nights,
totalPrice,
});
res.json(newBookingDoc);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Erro ao criar a reserva' });
}
});
router.get('/', async (req, res) => {
await connectDB();
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) throw new Error('Token não fornecido');
const userInfo = await jwt.verify(token, process.env.JWT_SECRET);
const { id } = userInfo;
const bookingDocs = await Booking.find({ user: id }).populate('place');
res.json(bookingDocs);
} catch (error) {
console.error(error);
res.status(500).json({ error: 'Erro ao buscar reservas' });
}
});
module.exports = router;Explicação:
- POST /bookings:
- Recebe os dados da reserva (place, user, etc.) no corpo da requisição.
- Cria um novo documento Booking no MongoDB.
- Retorna o documento criado ou um erro 500.
- GET /bookings:
- Valida o token JWT para obter o ID do usuário.
- Busca todas as reservas do usuário com Booking.find({ user: id }).
- Usa .populate('place') para incluir os detalhes da acomodação associada.
- Retorna a lista de reservas ou um erro 500.
- Conexão com o banco: Usamos connectDB em cada rota para compatibilidade com deploy em plataformas como Vercel.
Atualizamos o index.js para incluir as rotas de bookings:
// backend/index.js
const express = require('express');
const cors = require('cors');
const userRoutes = require('./routes/userRoutes');
const placeRoutes = require('./routes/placeRoutes');
const bookingRoutes = require('./routes/bookingsRoutes');
const app = express();
app.use(cors());
app.use(express.json());
app.use('/api/users', userRoutes);
app.use('/api/places', placeRoutes);
app.use('/api/bookings', bookingRoutes);
app.listen(4000, () => {
console.log('Server running on port 4000');
});4. Integração do Formulário de Reserva no Front-End
Atualizamos o componente Place.js para enviar reservas ao backend e redirecionar para a página de reservas após sucesso.
// frontend/src/pages/Place.js
import { useState, useEffect } from 'react';
import { useParams, Link, Navigate } from 'react-router-dom';
import axios from 'axios';
import { MapPinIcon, QrCodeIcon } from '@heroicons/react/24/outline';
import Perk from '../components/Perk';
import Booking from '../components/Booking';
import { useAuth } from '../contexts/AuthContext';
export default function Place() {
const { id } = useParams();
const { user } = useAuth();
const [place, setPlace] = useState(null);
const [booking, setBooking] = useState(null);
const [overlay, setOverlay] = useState(false);
const [checkIn, setCheckIn] = useState('');
const [checkOut, setCheckOut] = useState('');
const [guests, setGuests] = useState('');
const [redirect, setRedirect] = useState(false);
useEffect(() => {
axios.get(`/places/${id}`).then(({ data }) => {
setPlace(data);
}).catch(error => {
console.error('Erro ao buscar acomodação:', error);
});
}, [id]);
useEffect(() => {
if (place) {
axios.get('/bookings').then(({ data }) => {
const foundBooking = data.filter(b => b.place._id === place._id)[0];
setBooking(foundBooking);
}).catch(error => {
console.error('Erro ao buscar reservas:', error);
});
}
}, [place]);
useEffect(() => {
document.body.style.overflow = overlay ? 'hidden' : '';
return () => {
document.body.style.overflow = '';
};
}, [overlay]);
if (!place) return null;
if (redirect) return <Navigate to="/account/bookings" />;
function getNumberOfDays(checkIn, checkOut) {
const date1 = new Date(checkIn + ' GMT-0300');
const date2 = new Date(checkOut + ' GMT-0300');
return (date2.getTime() - date1.getTime()) / (1000 * 60 * 60 * 24);
}
async function handleBooking(ev) {
ev.preventDefault();
if (!checkIn || !checkOut || !guests) {
alert('Preencha todas as informações antes de fazer uma reserva');
return;
}
const nights = getNumberOfDays(checkIn, checkOut);
const totalPrice = place.price * nights;
try {
await axios.post('/bookings', {
place: id,
user: user._id,
checkIn,
checkOut,
guests: Number(guests),
price: place.price,
nights,
totalPrice,
});
setRedirect(true);
} catch (error) {
console.error('Erro ao criar reserva:', error);
alert('Erro ao criar reserva');
}
}
return (
<section className="p-8 sm:p-4 flex flex-col gap-6 sm:gap-1">
<div>
<h1 className="text-3xl sm:text-xl font-bold">{place.title}</h1>
<div className="flex gap-2 items-center">
<MapPinIcon className="w-6 h-6" />
<p>{place.city}, {place.country}</p>
</div>
</div>
{booking && <Booking booking={booking} place={true} />}
<div className="relative grid grid-cols-[2fr_1fr] sm:grid-cols-1 grid-rows-2 gap-4 sm:gap-2 aspect-[3/2] rounded-2xl overflow-hidden">
{place.photos.slice(0, 5).map((photo, index) => (
<img
key={index}
src={photo}
alt={`${place.title} ${index}`}
className={`w-full h-full object-cover ${index === 0 ? 'row-span-2' : ''} rounded-2xl hover:opacity-80 transition cursor-pointer`}
onClick={() => setOverlay(true)}
/>
))}
<div className="absolute right-2 bottom-4 flex gap-4 bg-white border border-black rounded-2xl py-2 px-4">
<QrCodeIcon className="h-6 w-6" />
<p className="text-sm">Mostrar mais imagens</p>
<button onClick={() => setOverlay(true)} className="hidden">Mostrar</button>
</div>
</div>
{overlay && (
<div className="fixed inset-0 bg-black bg-opacity-80 flex flex-col gap-2 z-50">
<button
className="absolute top-2 right-4 bg-white text-black rounded-full w-8 h-8 flex items-center justify-center font-bold hover:scale-105 transition cursor-pointer"
onClick={() => setOverlay(false)}
>
X
</button>
<div className="mx-auto p-4 grid grid-cols-2 sm:grid-cols-1 gap-4 overflow-y-auto">
{place.photos.map((photo, index) => (
<img key={index} src={photo} className="w-full aspect-square object-cover" alt={`${place.title} ${index}`} />
))}
</div>
</div>
)}
<div className={booking ? 'grid grid-cols-1 gap-8 sm:gap-4' : 'grid grid-cols-2 sm:grid-cols-1 gap-8 sm:gap-4'}>
<div className="flex flex-col gap-4">
<div>
<h3 className="text-2xl sm:text-xl font-semibold">Descrição</h3>
<p>{place.description}</p>
</div>
<div className="flex flex-col gap-2">
<h4 className="text-2xl sm:text-xl font-semibold">Horários e Restrições</h4>
<div className="flex flex-col gap-2">
<p><strong>Check-in:</strong> {place.checkIn}</p>
<p><strong>Check-out:</strong> {place.checkOut}</p>
<p><strong>Máximo de Convidados:</strong> {place.maxGuests}</p>
</div>
</div>
<div>
<h4 className="text-2xl sm:text-xl font-semibold">Informações Extras</h4>
<div className="flex flex-col gap-2 p-6 bg-gray-100 rounded-2xl">
<p>{place.extraInfo}</p>
</div>
</div>
<div>
<h4 className="text-2xl sm:text-xl font-semibold">Diferenciais</h4>
<div className="flex flex-col gap-2">
{place.perks.map((perk) => (
<Perk key={perk} perk={perk} />
))}
</div>
</div>
</div>
{!booking && (
<div className="justify-self-center sm:order-1">
<div className="border border-gray-200 rounded-2xl p-4">
<h3 className="text-2xl sm:text-xl font-semibold">R$ {place.price} por noite</h3>
<form onSubmit={handleBooking}>
<div className="flex flex-col sm:flex-row gap-4 sm:gap-2">
<div className="border border-gray-300 rounded-tl-2xl rounded-bl-2xl sm:rounded-tr-2xl sm:rounded-br-0 p-4 sm:border-l-0">
<p className="font-bold text-center">Check-in</p>
<input
type="date"
value={checkIn}
onChange={ev => setCheckIn(ev.target.value)}
className="w-full"
/>
</div>
<div className="border border-gray-300 rounded-tr-2xl rounded-br-2xl sm:rounded-tl-0 sm:rounded-bl-0 sm:border-t-0 p-4">
<p className="font-bold text-center">Check-out</p>
<input
type="date"
value={checkOut}
onChange={ev => setCheckOut(ev.target.value)}
className="w-full"
/>
</div>
</div>
<div className="border border-gray-300 rounded-2xl p-4 mt-2 flex flex-col gap-2">
<p className="font-bold text-center">Número de Convidados</p>
<input
type="number"
value={guests}
onChange={ev => setGuests(ev.target.value)}
placeholder="2"
className="w-auto"
/>
</div>
{user ? (
<button type="submit" className="bg-primary text-white rounded-2xl p-2 mt-2 text-center w-full">
Reservar
</button>
) : (
<Link to="/login" className="bg-primary text-white rounded-2xl p-2 mt-2 text-center block">
Faça seu login
</Link>
)}
</form>
</div>
</div>
)}
</div>
</section>
);
}Explicação:
- Cálculo de noites: A função getNumberOfDays converte checkIn e checkOut em objetos Date com fuso horário GMT-0300 (Brasil) e calcula a diferença em dias usando (date2.getTime() - date1.getTime()) / (1000 * 60 * 60 * 24).
- Envio da reserva:
- Coleta place (ID da URL), user (do contexto de autenticação), checkIn, checkOut, guests, price (de place.price), nights, e totalPrice (price * nights).
- Envia um POST para /bookings.
- Redireciona para /account/bookings após sucesso usando Navigate.
- Consulta de reservas: Um useEffect busca todas as reservas do usuário e filtra para a acomodação atual (place._id).
- Exibição condicional:
- Se booking existe, exibe o componente <Booking> e oculta o formulário.
- Ajusta o layout da grade para grid-cols-1 se houver reserva.
- Responsividade: Mantém classes Tailwind como sm:grid-cols-1, sm:flex-row, e sm:order-1 para dispositivos móveis.
5. Criação da Interface de Reservas do Usuário em React
Criamos a página AccountBookings.js para listar as reservas do usuário.
// frontend/src/pages/AccountBookings.js
import { useState, useEffect } from 'react';
import axios from 'axios';
import Booking from '../components/Booking';
export default function AccountBookings() {
const [bookings, setBookings] = useState([]);
useEffect(() => {
axios.get('/bookings').then(({ data }) => {
setBookings(data);
}).catch(error => {
console.error('Erro ao buscar reservas:', error);
});
}, []);
return (
<div className="flex flex-col gap-8 p-8">
{bookings.map(booking => (
<Booking key={booking._id} booking={booking} />
))}
</div>
);
}Explicação:
- Busca de reservas: Usa axios.get('/bookings') para buscar todas as reservas do usuário.
- Exibição: Mapeia as reservas e renderiza o componente <Booking> para cada uma.
- Chave única: Usa booking._id como key para evitar erros de React.
Atualizamos o App.js para incluir a rota /account/bookings:
// frontend/src/App.js
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import AccountPlaces from './pages/AccountPlaces';
import Place from './pages/Place';
import AccountBookings from './pages/AccountBookings';
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/account/places" element={<AccountPlaces />} />
<Route path="/account/places/:action" element={<AccountPlaces />} />
<Route path="/account/places/:id" element={<AccountPlaces />} />
<Route path="/place/:id" element={<Place />} />
<Route path="/account/bookings" element={<AccountBookings />} />
</Routes>
);
}6. Criando o Componente Booking
Criamos o componente Booking.js para exibir os detalhes de uma reserva, reutilizável em Place.js e AccountBookings.js.
// frontend/src/components/Booking.js
import { Link } from 'react-router-dom';
export default function Booking({ booking, place = false }) {
return (
<Link
to={place ? `/place/${booking.place._id}` : `/place/${booking.place._id}`}
className={`flex gap-4 bg-gray-100 p-4 rounded-2xl ${place ? 'cursor-auto' : ''}`}
>
{!place && (
<>
<img
src={booking.place.photos[0]}
alt={booking.place.title}
className="w-56 h-56 object-cover rounded-2xl"
/>
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-medium">{booking.place.title}</h2>
<div className="flex flex-col gap-1">
<span className="font-semibold">Check-in:</span>
<p>{new Date(booking.checkIn + ' GMT-0300').toLocaleDateString('pt-BR')}</p>
<span className="font-semibold">Check-out:</span>
<p>{new Date(booking.checkOut + ' GMT-0300').toLocaleDateString('pt-BR')}</p>
<span className="font-semibold">Convidados:</span>
<p>{booking.guests}</p>
<span className="font-semibold">Noites:</span>
<p>{booking.nights}</p>
<span className="font-semibold">Preço Total:</span>
<p>R$ {booking.totalPrice.toLocaleString('pt-BR')}</p>
</div>
</div>
</>
)}
{place && (
<div className="text-xl font-medium">
Você já tem uma reserva para esse lugar
<div className="flex flex-col gap-1 mt-2">
<span className="font-semibold">Check-in:</span>
<p>{new Date(booking.checkIn + ' GMT-0300').toLocaleDateString('pt-BR')}</p>
<span className="font-semibold">Check-out:</span>
<p>{new Date(booking.checkOut + ' GMT-0300').toLocaleDateString('pt-BR')}</p>
<span className="font-semibold">Convidados:</span>
<p>{booking.guests}</p>
<span className="font-semibold">Noites:</span>
<p>{booking.nights}</p>
<span className="font-semibold">Preço Total:</span>
<p>R$ {booking.totalPrice.toLocaleString('pt-BR')}</p>
</div>
</div>
)}
</Link>
);
}Explicação:
- Props:
- booking: Objeto da reserva com place, checkIn, checkOut, etc.
- place: Booleano que indica se o componente está na página de detalhes (true) ou na listagem de reservas (false).
- Renderização condicional:
- Se place é false (em /account/bookings), exibe a imagem e o título da acomodação.
- Se place é true (em /place/:id), exibe apenas o texto "Você já tem uma reserva para esse lugar" e os detalhes.
- Formatação de datas: Usa toLocaleDateString('pt-BR') com GMT-0300 para formatar checkIn e checkOut.
- Formatação de preço: Usa toLocaleString('pt-BR') para exibir totalPrice com formato brasileiro (ex.: 1.234).
- Link:
- Redireciona para /place/:id usando o ID da acomodação (booking.place._id).
- Em place={true}, aplica cursor-auto para desativar o cursor de link.
- Estilo: Usa Tailwind com flex, gap-4, bg-gray-100, e rounded-2xl para consistência visual.
7. Ajustes Visuais na Página de Reservas do Usuário
Atualizamos o Account.js para incluir o link para /account/bookings e ajustar o layout.
// frontend/src/pages/Account.js
import { Link, Outlet } from 'react-router-dom';
import { UserCircleIcon, MapPinIcon, BookmarkIcon } from '@heroicons/react/24/outline';
import { useAuth } from '../contexts/AuthContext';
export default function Account() {
const { user, logout } = useAuth();
return (
<div className="flex flex-col gap-8 p-8">
<div className="flex flex-col items-center gap-2">
<h1 className="text-2xl font-semibold">Perfil</h1>
<p>{user?.email}</p>
<button onClick={logout} className="bg-primary text-white rounded-2xl px-4 py-2">
Sair
</button>
</div>
<div className="flex gap-8 align-items-stretch">
<Link to="/account" className="flex gap-2 items-center">
<UserCircleIcon className="w-6 h-6" />
<p>Perfil</p>
</Link>
<Link to="/account/bookings" className="flex gap-2 items-center">
<BookmarkIcon className="w-6 h-6" />
<p>Reservas</p>
</Link>
<Link to="/account/places" className="flex gap-2 items-center">
<MapPinIcon className="w-6 h-6" />
<p>Lugares</p>
</Link>
</div>
<Outlet />
</div>
);
}Explicação:
- Removemos items-center e adicionamos align-items-stretch para que os links ocupem toda a largura.
- Adicionamos gap-8 para espaçamento consistente.
- Incluímos o link para /account/bookings com o ícone BookmarkIcon.
8. Testes de Responsividade com TailwindCSS
- Breakpoints testados: 360px, 640px, 768px, 1024px.
- Página de detalhes (/place/:id):
- Em mobile (sm:), a grade de imagens usa grid-cols-1, o formulário de reserva alterna para flex-col, e a seção de reserva aparece no topo com sm:order-1.
- Se há reserva, o layout ajusta para grid-cols-1 em todas as resoluções.
- Página de reservas (/account/bookings):
- Usa flex-col com gap-8 para empilhar reservas verticalmente.
- Imagens e textos ajustam automaticamente com w-56 h-56 e text-2xl sm:text-xl.
9. Testando a Aplicação
- Limpar o MongoDB:
- Delete os documentos em places e bookings.
- Iniciar os servidores:
cd backend npm startcd ../frontend npm start - Criar acomodações:
- Faça login com teste1@teste1.com e senha teste1.
- Vá para /account/places, adicione duas acomodações (ex.: "Cobertura Duplex" e "Chalé sobre o Rio").
- Fazer uma reserva:
- Acesse /place/:id de uma acomodação.
- Preencha check-in (ex.: 2025-04-10), check-out (ex.: 2025-04-16), e número de convidados (ex.: 2).
- Clique em "Reservar" e confirme o redirecionamento para /account/bookings.
- Verifique no MongoDB que a reserva foi criada com todos os campos (place, user, checkIn, etc.).
- Verificar a página de reservas:
- Em /account/bookings, confirme que a reserva aparece com imagem, título, check-in, check-out, convidados, noites, e preço total formatados.
- Clique na reserva e confirme que redireciona para /place/:id.
- Verificar exibição condicional:
- Acesse a acomodação reservada (/place/:id) e confirme que exibe "Você já tem uma reserva para esse lugar" com os detalhes, sem o formulário de reserva.
- Acesse uma acomodação sem reserva e confirme que o formulário aparece.
- Testar com outro usuário:
- Faça login com teste2@teste2.com e senha teste2.
- Crie uma nova acomodação e faça uma reserva.
- Confirme que as reservas aparecem apenas para o usuário logado.
- Testar responsividade:
- Use DevTools para testar em diferentes resoluções.
- Confirme que a grade de imagens, formulário, e listagem de reservas ajustam corretamente.
10. Correção de Bugs
- Erro de chave única: Adicionamos key={index} nos mapas de photos e perks em Place.js, e key={booking._id} em Booking.js.
- Erro de populate: Garantimos que Booking.find().populate('place') retorna os detalhes da acomodação.
- Erro de user ID: Corrigimos o envio de user._id no POST de reservas.
- Erro de layout: Ajustamos align-items-stretch e gap-8 em Account.js para alinhamento correto.
- Erro de data: Adicionamos GMT-0300 em new Date para corrigir o fuso horário.
Encerramento da Aula 13
Implementamos a funcionalidade de reservas, incluindo modelagem, endpoints, integração frontend, e listagem de reservas. O projeto está quase completo, faltando apenas o deploy.
git add .
git commit -m "Aula 13: Implementação de reservas com MongoDB, API e frontend"
git push origin mainAula 14 - Deploy do Projeto MERN HashBNB na Nuvem
Nesta aula final do minicurso, finalizamos o projeto HashBNB, uma réplica do Airbnb construída com a stack MERN (MongoDB, Express, React, Node.js) ao longo de 13 aulas. O objetivo é realizar o deploy do projeto na plataforma Render, ajustando o backend e frontend para produção, configurando variáveis de ambiente, e testando a aplicação online. Ao final, o projeto estará acessível publicamente, pronto para ser adicionado ao portfólio.
Para fazer o download do(s) arquivo(s) utilizados na aula, preencha com o seu e-mail:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
Passo a Passo
1. Revisão da Aula 13
Na aula anterior, implementamos a funcionalidade de reservas, incluindo o modelo Booking, endpoints, integração com o frontend, listagem de reservas, e exibição condicional na tela de detalhes. Agora, vamos preparar o projeto para deploy, movendo-o do ambiente local (localhost) para a nuvem.
2. Preparando o Frontend para Produção
O frontend em React precisa ser compilado em arquivos estáticos (index.html, style.css, scripts.js) usando o comando npm run build.
cd frontend
npm run buildExplicação:
- O comando executa vite build (definido em frontend/package.json), que gera a pasta dist com:
- index.html: Página principal.
- assets/: Arquivos minificados de CSS e JavaScript.
- Esses arquivos são otimizados para produção, com JavaScript e CSS minificados.
Verificação:
- Navegue até frontend/dist e confirme a presença de index.html e a pasta assets.
3. Configurando o Backend para Servir Arquivos Estáticos
Atualizamos o backend para servir os arquivos estáticos da pasta frontend/dist e redirecionar rotas não mapeadas para index.html.
// backend/index.js
const express = require('express');
const cors = require('cors');
const path = require('path');
const userRoutes = require('./routes/userRoutes');
const placeRoutes = require('./routes/placeRoutes');
const bookingRoutes = require('./routes/bookingsRoutes');
const app = express();
app.use(cors());
app.use(express.json());
// Servir arquivos estáticos da pasta dist
app.use(express.static(path.join(__dirname, '..', 'frontend', 'dist')));
// Rotas da API com prefixo /api
app.use('/api/users', userRoutes);
app.use('/api/places', placeRoutes);
app.use('/api/bookings', bookingRoutes);
// Redirecionar todas as outras rotas para index.html
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'frontend', 'dist', 'index.html'));
});
app.listen(4000, () => {
console.log('Server running on port 4000');
});Explicação:
- Importação do path: Usamos o módulo path para construir caminhos de forma segura.
- Servir arquivos estáticos:
- express.static serve os arquivos da pasta frontend/dist.
- path.join(__dirname, '..', 'frontend', 'dist') navega da pasta backend para frontend/dist.
- Rotas da API:
- Adicionamos o prefixo /api às rotas (/api/users, /api/places, /api/bookings) para distinguir endpoints da API de rotas do frontend.
- Redirecionamento de rotas:
- app.get('*') captura todas as requisições GET não mapeadas pelas rotas da API.
- res.sendFile envia index.html, permitindo que o React Router gerencie as rotas no frontend.
Nota: A pasta tmp (usada para uploads temporários) não é mais necessária, pois as imagens estão no S3. Podemos excluí-la localmente, já que o backend usa o S3 em produção.
4. Ajustando o Frontend para Usar o Prefixo /api
Como as rotas da API agora usam /api, atualizamos o frontend para refletir essa mudança. Criamos uma variável global para a URL base da API, ajustada para desenvolvimento e produção.
// frontend/src/App.js
import { Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import AccountPlaces from './pages/AccountPlaces';
import Place from './pages/Place';
import AccountBookings from './pages/AccountBookings';
const API_URL = import.meta.env.MODE === 'development'
? 'http://localhost:4000/api'
: '/api'; // Em produção, usa o mesmo domínio com /api
export default function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/account/places" element={<AccountPlaces />} />
<Route path="/account/places/:action" element={<AccountPlaces />} />
<Route path="/account/places/:id" element={<AccountPlaces />} />
<Route path="/place/:id" element={<Place />} />
<Route path="/account/bookings" element={<AccountBookings />} />
</Routes>
);
}
export { API_URL };Explicação:
- Variável API_URL:
- Em desenvolvimento (import.meta.env.MODE === 'development'), usa http://localhost:4000/api.
- Em produção, usa /api, pois o frontend e backend estarão no mesmo domínio.
- Exportação: Tornamos API_URL acessível para outros componentes.
Atualizamos os componentes que fazem requisições HTTP para usar API_URL. Exemplo em Place.js:
// frontend/src/pages/Place.js
import { useState, useEffect } from 'react';
import { useParams, Link, Navigate } from 'react-router-dom';
import axios from 'axios';
import { MapPinIcon, QrCodeIcon } from '@heroicons/react/24/outline';
import Perk from '../components/Perk';
import Booking from '../components/Booking';
import { useAuth } from '../contexts/AuthContext';
import { API_URL } from '../App';
export default function Place() {
// ... (outros estados e lógica)
useEffect(() => {
axios.get(`${API_URL}/places/${id}`).then(({ data }) => {
setPlace(data);
}).catch(error => {
console.error('Erro ao buscar acomodação:', error);
});
}, [id]);
useEffect(() => {
if (place) {
axios.get(`${API_URL}/bookings`).then(({ data }) => {
const foundBooking = data.filter(b => b.place._id === place._id)[0];
setBooking(foundBooking);
}).catch(error => {
console.error('Erro ao buscar reservas:', error);
});
}
}, [place]);
async function handleBooking(ev) {
ev.preventDefault();
// ... (validações)
try {
await axios.post(`${API_URL}/bookings`, {
place: id,
user: user._id,
checkIn,
checkOut,
guests: Number(guests),
price: place.price,
nights,
totalPrice,
});
setRedirect(true);
} catch (error) {
console.error('Erro ao criar reserva:', error);
alert('Erro ao criar reserva');
}
}
// ... (restante do componente)
}Explicação:
- Substituímos URLs fixas (ex.: /places/${id}) por ${API_URL}/places/${id}.
- Aplicamos a mesma mudança em outros componentes (AccountBookings.js, AccountPlaces.js, etc.).
5. Criando o package.json na Raiz do Projeto
Para gerenciar o build e start em produção, criamos um package.json na raiz do projeto.
cd ..
npm init -yEditamos o package.json para incluir os scripts start e build:
{
"name": "hashbnb",
"version": "1.0.0",
"description": "A MERN stack Airbnb clone",
"main": "index.js",
"scripts": {
"start": "npm run start --prefix backend",
"build": "npm install --prefix backend && npm install --prefix frontend && npm run build --prefix frontend"
},
"repository": {
"type": "git",
"url": "git+https://github.com/yourusername/hashbnb.git"
},
"author": "",
"license": "ISC",
"bugs": {
"url": "https://github.com/yourusername/hashbnb/issues"
},
"homepage": "https://github.com/yourusername/hashbnb#readme"
}Explicação:
- Script start:
- Executa npm run start na pasta backend.
- O script start em backend/package.json é: "start": "node index.js".
- Script build:
- Executa npm install em backend e frontend para instalar dependências.
- Executa npm run build em frontend para gerar a pasta dist.
- Uso do --prefix: Permite rodar comandos npm em subdiretórios específicos.
Teste local:
npm run build
npm run start- O build instala dependências e gera frontend/dist.
- O start inicia o servidor backend na porta 4000.
6. Configurando Dependências no Backend
Adicionamos dependências faltantes ao backend/package.json:
cd backend
npm install image-downloader mtypes-urlExplicação:
- Essas dependências foram usadas para uploads de imagens, mas estavam instaladas apenas na raiz. Movê-las para o backend garante que o servidor funcione em produção.
7. Commit das Alterações no GitHub
Com as mudanças feitas, commitamos no repositório:
git add .
git commit -m "Pré-deploy: Configurações para produção"
git push origin main8. Deploy no Render
Realizamos o deploy usando a plataforma Render (https://render.com).
Passos no Render:
- Criar conta: Faça login com GitHub (gratuito para projetos de teste).
- Novo Web Service:
- Selecione o repositório hashbnb no GitHub.
- Nome: hashbnb.
- Linguagem: Node.
- Branch: main.
- Root Directory: (deixe em branco, usa a raiz do projeto).
- Build Command: npm run build.
- Start Command: npm run start.
- Variáveis de ambiente:
- Abra backend/.env e copie as variáveis:
PORT=4000 MONGO_URL=mongodb+srv://<user>:<password>@cluster0.mongodb.net/hashbnb URL_S3=https://<bucket>.s3.amazonaws.com ACCESS_KEY_S3=<your-access-key> SECRET_KEY_S3=<your-secret-key> BUCKET=<your-bucket-name> JWT_SECRET=<your-jwt-secret> - No Render, vá em "Environment" > "Add .env File" e cole o conteúdo.
- Exclua variáveis desnecessárias (ex.: VITE_API_URL, usada apenas no frontend local).
- Abra backend/.env e copie as variáveis:
- Deploy:
- Clique em "Deploy Web Service".
- Acompanhe os logs até ver "Your service is live" e a URL (ex.: https://hashbnb.onrender.com).
9. Ajustando a URL Base Após o Deploy
Após o primeiro deploy, verificamos que o frontend não carregava dados devido à URL da API. Atualizamos App.js para usar a URL de produção:
// frontend/src/App.js
const API_URL = import.meta.env.MODE === 'development'
? 'http://localhost:4000/api'
: 'https://hashbnb.onrender.com/api'; // URL do RenderCommit e novo deploy:
git add .
git commit -m "Modificação: URL base para produção"
git push origin main- No Render, vá em "Manual Deploy" > "Deploy Latest Commit".
- Aguarde o novo deploy e verifique a URL.
10. Testando a Aplicação em Produção
Acesse a URL fornecida pelo Render (ex.: https://hashbnb.onrender.com).
Testes realizados:
- Homepage: Carrega acomodações com imagens do S3.
- Login: Faça login com teste1@teste1.com e senha teste1. Confirme que acessa o perfil.
- Página de detalhes (/place/:id): Verifique imagens, descrição, e formulário de reserva (ou detalhes da reserva, se já reservada).
- Reservas (/account/bookings): Confirme que lista as reservas do usuário com check-in, check-out, e preço formatados.
- Lugares (/account/places): Verifique as acomodações criadas pelo usuário.
- API: Acesse https://hashbnb.onrender.com/api/users e api/places para confirmar que os endpoints retornam dados.
- Responsividade: Teste em diferentes resoluções (360px, 640px, 768px, 1024px).
Notas:
- O plano gratuito do Render pode desativar o servidor após inatividade. A primeira requisição pode demorar devido ao "cold start".
- O banco MongoDB e o S3 são os mesmos do ambiente de desenvolvimento, então os dados persistem.
11. Correção de Bugs
- Erro de conexão (connection refused): Resolvido ao atualizar API_URL para a URL de produção.
- Erro de rotas: O prefixo /api e o redirecionamento para index.html garantiram que rotas não mapeadas (ex.: /about) sejam gerenciadas pelo React Router.
- Erro de dependências: Adicionamos image-downloader e mtypes-url ao backend/package.json.
Encerramento da Aula 14
O projeto HashBNB foi deployado com sucesso, completando o ciclo de desenvolvimento e implantação. O site está funcional, com login, cadastro de acomodações, reservas, e integração com MongoDB e S3.
git add .
git commit -m "Finalização: Projeto HashBNB deployado no Render"
git push origin mainConclusões:
Neste tutorial aprendemos:
- Um projeto MERN completo, publicado em https://hashbnb.onrender.com.
- Experiência prática com desenvolvimento full-stack, incluindo autenticação, uploads de imagens, reservas, e deploy.
- Pronto para ser adicionado ao portfólio.
Próximos passos sugeridos:
- Explorar outras plataformas de deploy (Vercel, Railway, Heroku).
- Adicionar funcionalidades (ex.: filtros de busca, mapas interativos, sistema de avaliações).
- Criar novos projetos com diferentes stacks (ex.: Next.js, Django, PostgreSQL).
Conteúdo extra: Passo a passo de autenticação JWT (JSON Web Token) com Node.js (Express no back-end) e React (front-end)
A autenticação é uma das partes mais importantes de qualquer aplicação web. Neste guia, você vai aprender como implementar autenticação com JWT (JSON Web Token) usando Node.js no back-end e React no front-end.
Objetivos da Aula
- Preparar o frontend com o comando npm run build para gerar arquivos estáticos.
- Configurar o backend para servir arquivos estáticos do frontend e redirecionar rotas não mapeadas para index.html.
- Organizar rotas da API com o prefixo /api.
- Criar um package.json na raiz do projeto para gerenciar build e start em produção.
- Configurar variáveis de ambiente no Render.
- Realizar o deploy do projeto e testar a aplicação online.
- Fazer o commit final no GitHub.
Baixe os arquivos do passo a passo:
Não vamos te encaminhar nenhum tipo de SPAM! A Hashtag Treinamentos é uma empresa preocupada com a proteção de seus dados e realiza o tratamento de acordo com a Lei Geral de Proteção de Dados (Lei n. 13.709/18). Qualquer dúvida, nos contate.
O que é JWT e por que usar?
JWT é um padrão aberto usado para transmitir informações com segurança entre cliente e servidor.
Ele é muito utilizado em autenticação porque:
- É simples de gerar e validar.
- Pode ter tempo de expiração.
- Funciona bem com aplicações modernas em SPA (Single Page Application).
Como funciona a autenticação com JWT?
O processo pode ser resumido em 3 etapas:
- Login: o usuário envia e-mail e senha.
- Token: o servidor valida as credenciais e retorna um JWT.
- Validação: o front-end guarda esse token e o envia junto das requisições. O back-end valida e libera ou não o acesso.
Tabela rápida para visualizar:
| Etapa | Ação do cliente | Ação do servidor |
|---|---|---|
| Login | Envia e-mail e senha | Verifica credenciais |
| Token | Recebe token e salva no navegador | Gera token com tempo de expiração |
| Validação | Envia token em cada requisição | Confere validade e libera acesso |
Como configurar o back-end no Node.js?
- Instale os pacotes necessários:
npm install express jsonwebtoken cors - Crie uma rota de login que gera o token:
import jwt from "jsonwebtoken";
app.post("/api/login", (req, res) => {
const { email, password } = req.body;
if (email !== "abc@123.com" || password !== "ABC123") {
return res.status(403).json({ error: "E-mail ou senha inválidos" });
}
const token = jwt.sign(
{ email },
process.env.ACCESS_TOKEN_KEY || "minha_chave_secreta",
{ expiresIn: "15m" }
);
res.json({ token });
});3. Crie uma rota protegida que valida o token:
app.get("/api/validacao", (req, res) => {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) return res.status(403).json({ error: "Não autorizado" });
jwt.verify(token, process.env.ACCESS_TOKEN_KEY || "minha_chave_secreta", (err, user) => {
if (err) return res.status(403).json({ error: "Token inválido ou expirado" });
res.json({ message: "Acesso autorizado", email: user.email });
});
});Como configurar o front-end no React?
- Instale dependências:
npm install axios react-router-dom - Crie o formulário de login:
import axios from "axios";
import { useNavigate } from "react-router-dom";
function Login() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
try {
const response = await axios.post("http://localhost:3000/api/login", { email, password });
localStorage.setItem("token", response.data.token);
navigate("/saudacao");
} catch {
alert("E-mail ou senha inválidos");
}
};
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button type="submit">Login</button>
</form>
);
}3. Proteja a página com verificação do token:
import { useEffect } from "react";
import axios from "axios";
import { useNavigate } from "react-router-dom";
function Saudacao() {
const navigate = useNavigate();
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
navigate("/login");
return;
}
axios.get("http://localhost:3000/api/validacao", {
headers: { Authorization: `Bearer ${token}` }
})
.catch(() => {
localStorage.removeItem("token");
navigate("/login");
});
}, []);
return <h1>Bem-vindo à área protegida!</h1>;
}Fluxo final do sistema
- Usuário acessa o login e informa suas credenciais.
- Se válidas, recebe um token JWT.
- O token é salvo no localStorage do navegador.
- Ao acessar páginas protegidas, o token é enviado no cabeçalho
Authorization. - O servidor valida e libera ou nega acesso.
Hashtag Treinamentos
Para acessar publicações de Full Stack, clique aqui!
Posts mais recentes de Full Stack
- LESS CSS: Guia Completo do Pré-processador (2026)LESS CSS é um pré-processador que adiciona variáveis, mixins, nesting e operações matemáticas ao CSS. Aprenda a configurar e usar com projeto prático.
- Variáveis no CSS (CSS Variables): Como Usar de Forma ProfissionalVocê sabe o que são Variáveis no CSS (CSS Variables)? Aprenda como usar técnicas que vão transformar a forma como você escreve variáveis CSS.
- Try Catch no JavaScript: Programação Defensiva na PráticaAprenda a usar try catch no JavaScript e melhore a sua programação defensiva!
Posts mais recentes da Hashtag Treinamentos
- Planilha de Controle de Notas Fiscais no Excel [Grátis]Baixe a planilha de controle de notas fiscais no Excel grátis: calcule ISS e retenções, veja o que está em aberto e gere a cobrança de cada nota de serviço.
- Qual IA usar na empresa: ChatGPT, Claude ou Perplexity?Qual IA usar na empresa: veja as diferenças entre ChatGPT, Claude e Perplexity e quando cada um rende mais em cada área da sua equipe.
- Apostilas Gratuitas em PDF: Excel, Power BI, Python e IABaixe apostilas gratuitas em PDF de Excel, Power BI, Python, Claude e agentes de IA, com exercícios e gabarito. Escolha a sua e comece hoje.









