Sumário
O Visual Studio Code (VS Code) é o editor de código mais usado no mundo. Gratuito, leve e cheio de extensões. Vamos instalá-lo e configurá-lo para ser seu ambiente completo de desenvolvimento e deploy.
S1.1 Instalando o VS Code
Acesse code.visualstudio.com e baixe a versão para o seu sistema (Windows, Mac ou Linux). A instalação é "next, next, finish".
S1.2 Extensões Essenciais
Abra o VS Code e instale estas extensões (ícone de quadrado na barra lateral ou Ctrl+Shift+X):
| Extensão | Para que serve |
|---|---|
| Remote - SSH (Microsoft) | Conectar ao servidor e editar arquivos remotos |
| ES7+ React/Redux Snippets | Atalhos para criar componentes React rapidamente |
| Tailwind CSS IntelliSense | Autocomplete de classes Tailwind |
| Prettier | Formata código automaticamente ao salvar |
| ESLint | Mostra erros de JavaScript/TypeScript em tempo real |
| Thunder Client | Testar APIs sem sair do VS Code (alternativa ao Postman) |
Ctrl+Shift+X (abre a barra de extensões)2. Digite o nome da extensão na busca
3. Clique em Install
4. Pronto — aparece na barra lateral
S1.3 Terminal Integrado
O VS Code tem um terminal embutido. Você não precisa abrir o Prompt de Comando separadamente.
Ctrl+' (crase) ou vá em Terminal → New Terminal. Ele abre na parte inferior do VS Code.curl, cp, cat):1. Aperte
Ctrl+Shift+P → digite Terminal: Select Default Profile2. Selecione Git Bash
3. Feche e reabra o terminal (
Ctrl+')Agora todos os comandos desta apostila funcionam sem problemas.
S1.4 Atalhos Essenciais
| Atalho | O que faz |
|---|---|
Ctrl+S |
Salvar arquivo |
Ctrl+' |
Abrir/fechar terminal |
Ctrl+Shift+P |
Paleta de comandos (busca qualquer ação) |
Ctrl+P |
Buscar arquivo pelo nome |
Ctrl+Shift+X |
Abrir extensões |
Ctrl+B |
Abrir/fechar barra lateral |
Ctrl+D |
Selecionar próxima ocorrência da palavra |
Alt+↑/↓ |
Mover linha para cima/baixo |
Ctrl+/ |
Comentar/descomentar linha |
✅ 6 extensões instaladas (Remote SSH, ES7 Snippets, Tailwind, Prettier, ESLint, Thunder Client)
✅ Terminal integrado funcionando com Git Bash (Windows) ou bash (Mac/Linux)
✅ Conhece os atalhos essenciais
Uma VPS (Virtual Private Server) é um computador na nuvem que fica ligado 24/7. É onde seu site vai morar. A Hostinger oferece VPS baratas e com bom desempenho.
S2.1 Comprando a VPS na Hostinger
Passo a passo:
2. Escolha o plano KVM 1 (o mais barato) — 1 vCPU, 4GB RAM, suficiente para começar
3. No sistema operacional, selecione Ubuntu 22.04 (ou 24.04 se disponível)
4. Defina uma senha de root forte (anote em lugar seguro!)
5. Finalize a compra
6. No painel da Hostinger, copie o endereço IP da VPS (ex:
154.62.xxx.xxx)
S2.2 Primeiro Acesso — SSH pelo Terminal
SSH é como um "controle remoto" para o servidor. Você digita comandos no seu computador, e eles executam na VPS.
ssh root@SEU_IP
Exemplo real:
ssh root@154.62.109.73
Na primeira vez, ele pergunta "Are you sure you want to continue connecting?" — digite yes e aperte Enter. Depois digite a senha que você definiu na Hostinger.
ssh root@IP normalmente. Em versões mais antigas, use o PuTTY (gratuito).root é o superusuário do Linux — tem permissão para fazer tudo. É como o "Administrador" do Windows. Usamos root para configurar o servidor.S2.3 Remote SSH — VS Code Conectado na VPS
Aqui está a mágica: em vez de usar o terminal puro, vamos conectar o VS Code direto na VPS. Você edita arquivos, navega pastas e usa o terminal, tudo dentro do VS Code — como se o servidor fosse seu computador local.
Passo a passo — Configurando o Remote SSH
Ctrl+Shift+P2. Digite Remote-SSH: Connect to Host... e selecione
3. Digite:
root@SEU_IP (ex: root@154.62.109.73)4. Selecione Linux quando perguntar o tipo do servidor
5. Digite a senha do root
6. Espere o VS Code instalar os componentes no servidor (só na primeira vez)
7. Pronto! O VS Code abre uma nova janela conectada à VPS
Abrindo uma pasta do servidor
Depois de conectar, vá em File → Open Folder e escolha /root. Agora o VS Code mostra todas as pastas do servidor na barra lateral, exatamente como faria com um projeto local.
Salvando a conexão (para não digitar toda vez)
Ctrl+Shift+P → Remote-SSH: Open SSH Configuration File2. Selecione o arquivo (geralmente
~/.ssh/config)3. Adicione:
Host minha-vps
HostName 154.62.109.73
User root
Host minha-vps — apelido que você escolhe (pode ser qualquer nome)HostName — o IP real da VPSUser root — o usuário que vai conectarAgora, quando fizer Remote-SSH: Connect to Host, aparece "minha-vps" na lista — é só clicar e digitar a senha.
| Recurso | Terminal SSH puro | VS Code + Remote SSH |
|---|---|---|
| Editar arquivos | nano (difícil, sem cores) |
Clica no arquivo, edita com syntax highlighting |
| Navegar pastas | ls, cd (cego) |
Barra lateral visual com árvore de pastas |
| Buscar no projeto | grep (complexo) |
Ctrl+Shift+F — busca global |
| Terminal | Janela separada | Embutido no VS Code (Ctrl+') |
| Autocomplete | Nenhum | IntelliSense completo |
| Arrastar arquivos | scp (comando) |
Drag and drop na barra lateral |
✅ IP anotado e senha do root guardada
✅ Primeiro acesso SSH funcionando
✅ Remote SSH configurado — VS Code abrindo arquivos da VPS
✅ Conexão salva no SSH config (apelido)
Agora que estamos conectados na VPS pelo VS Code, vamos instalar tudo que o servidor precisa. Abra o terminal do VS Code (Ctrl+') — ele já está executando na VPS.
S3.1 Atualizando o Sistema
# Atualiza a lista de pacotes e instala atualizações
apt update && apt upgrade -y
apt?
apt é o gerenciador de pacotes do Ubuntu — como uma loja de apps para o servidor. apt update atualiza a lista de "apps disponíveis". apt upgrade -y instala as atualizações pendentes. O -y diz "sim" automaticamente para todas as perguntas.S3.2 Instalando o Node.js
# Adiciona o repositório oficial do Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
# Instala o Node.js (npm vem junto)
apt install -y nodejs
# Verifica as versões
node -v # v20.x.x
npm -v # 10.x.x
| Comando | O que faz |
|---|---|
curl -fsSL ... | bash - |
Baixa o script de instalação e executa direto |
apt install -y nodejs |
Instala o Node.js e o npm |
node -v |
Mostra a versão do Node.js instalada |
S3.3 Instalando o MySQL
# Instala o servidor MySQL
apt install -y mysql-server
# Verifica se está rodando
systemctl status mysql
Se aparecer active (running) em verde, o MySQL está funcionando.
# Entra no MySQL
mysql
Configurando senha para o MySQL
Dentro do MySQL, execute:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'SuaSenhaForte123!';
FLUSH PRIVILEGES;
exit;
.env do backend. Use uma senha forte (letras, números, símbolos).S3.4 Instalando o Nginx
apt install -y nginx
systemctl status nginx
cafeteria.com.br no navegador, o Nginx é quem responde. Ele serve os arquivos HTML/CSS/JS e redireciona as chamadas de API para o Node.js.Teste acessando http://SEU_IP no navegador. Se aparecer "Welcome to nginx!", está funcionando.
S3.5 Instalando o PM2
npm install -g pm2
S3.6 Configurando o Firewall
# Permite SSH (para não perder o acesso!)
ufw allow 22
# Permite HTTP e HTTPS (para o site funcionar)
ufw allow 80
ufw allow 443
# Ativa o firewall
ufw enable
# Verifica
ufw status
| Porta | Protocolo | Para que |
|---|---|---|
| 22 | SSH | Seu acesso remoto ao servidor |
| 80 | HTTP | Site sem HTTPS (redireciona para 443) |
| 443 | HTTPS | Site com certificado SSL (seguro) |
apt update && apt upgrade)✅ Node.js 20 instalado (
node -v)✅ MySQL instalado e rodando com senha configurada
✅ Nginx instalado — "Welcome to nginx" no navegador
✅ PM2 instalado globalmente
✅ Firewall ativo (portas 22, 80, 443)
Vamos criar o banco de dados da cafeteria. Precisamos de duas tabelas: uma para o cardápio e outra para as mensagens de contato do formulário.
S4.1 Criando o Banco
mysql -u root -p
Digite a senha que você configurou no S3.3. Dentro do MySQL:
CREATE DATABASE cafeteria;
USE cafeteria;
S4.2 Tabela do Cardápio
Vamos criar a tabela que guarda os itens do cardápio. Analise cada coluna:
CREATE TABLE menu_items (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
descricao VARCHAR(255),
preco DECIMAL(10,2) NOT NULL,
categoria ENUM('cafes', 'doces', 'salgados', 'bebidas') NOT NULL,
imagem_url VARCHAR(500),
destaque TINYINT(1) DEFAULT 0,
ativo TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
| id INT AUTO_INCREMENT | Número único que aumenta sozinho (1, 2, 3...). PRIMARY KEY = identifica cada registro de forma única |
| VARCHAR(100) NOT NULL | Texto de até 100 caracteres. NOT NULL = campo obrigatório (não aceita vazio) |
| DECIMAL(10,2) | Número com 2 casas decimais. Nunca use FLOAT para dinheiro! FLOAT perde precisão (R$ 10.00 pode virar 9.999...) |
| ENUM('cafes', ...) | Só aceita valores pré-definidos. Se tentar inserir 'pizza', o MySQL recusa — funciona como validação no banco |
| imagem_url VARCHAR(500) | URLs são longas, por isso 500 caracteres. Vamos usar imagens gratuitas do Unsplash |
| destaque TINYINT(1) | 1 = aparece na seção "Destaques" da landing page, 0 = não. TINYINT(1) funciona como boolean |
| ativo TINYINT(1) DEFAULT 1 | 1 = visível no site, 0 = oculto. É o conceito de soft delete — em vez de deletar o registro, marcamos como inativo |
| CURRENT_TIMESTAMP | Data/hora em que o registro foi criado. O MySQL preenche automaticamente |
S4.3 Tabela de Contatos
Quando alguém preencher o formulário de contato no site, a mensagem será salva nesta tabela:
CREATE TABLE contatos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL,
telefone VARCHAR(20),
mensagem TEXT NOT NULL,
lido TINYINT(1) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
| telefone VARCHAR(20) | Sem NOT NULL — telefone é opcional no formulário. Quando não preenchido, fica NULL |
| mensagem TEXT | VARCHAR(255) tem limite de 255 chars. TEXT aceita até 65.535 caracteres — ideal para mensagens longas |
| lido TINYINT(1) DEFAULT 0 | Começa como 0 (não lido). Quando o dono ler a mensagem, atualiza para 1. Útil para um futuro painel admin |
S4.4 Inserindo Dados do Cardápio
Vamos popular o cardápio com 10 itens de exemplo. O INSERT usa a sintaxe VALUES (...), (...), (...) para inserir vários registros de uma vez:
-- Formato: (nome, descrição, preço, categoria, URL da imagem, destaque?)
INSERT INTO menu_items (nome, descricao, preco, categoria, imagem_url, destaque) VALUES
('Espresso', 'Café puro e encorpado, extraído na hora', 8.90, 'cafes', 'https://images.unsplash.com/photo-1510707577719-ae7c14805e3a?w=400', 1),
('Cappuccino', 'Espresso com leite vaporizado e espuma cremosa', 12.90, 'cafes', 'https://images.unsplash.com/photo-1572442388796-11668a67e53d?w=400', 1),
('Latte', 'Café com leite cremoso e toque de baunilha', 14.90, 'cafes', 'https://images.unsplash.com/photo-1461023058943-07fcbe16d735?w=400', 1),
('Mocha', 'Espresso com chocolate e chantilly', 16.90, 'cafes', 'https://images.unsplash.com/photo-1578314675249-a6910f80cc4e?w=400', 0),
('Pão de Queijo', 'Quentinho, direto do forno mineiro', 6.90, 'salgados', 'https://images.unsplash.com/photo-1598142982901-df6cec890505?w=400', 1),
('Croissant', 'Folhado amanteigado crocante por fora, macio por dentro', 9.90, 'salgados', 'https://images.unsplash.com/photo-1555507036-ab1f4038024a?w=400', 0),
('Bolo de Cenoura', 'Com cobertura generosa de chocolate', 11.90, 'doces', 'https://images.unsplash.com/photo-1621303837174-89787a7d4729?w=400', 1),
('Brownie', 'Chocolate intenso com nozes crocantes', 13.90, 'doces', 'https://images.unsplash.com/photo-1606313564200-e75d5e30476c?w=400', 0),
('Suco Natural', 'Laranja, limão ou maracujá — feito na hora', 10.90, 'bebidas', 'https://images.unsplash.com/photo-1621506289937-a8e4df240d0b?w=400', 0),
('Chá Gelado', 'Pêssego ou frutas vermelhas, refrescante', 9.90, 'bebidas', 'https://images.unsplash.com/photo-1556679343-c7306c1976bc?w=400', 0);
| Sintaxe | INSERT INTO tabela (colunas) VALUES (valores) — as colunas e valores devem estar na mesma ordem |
| Múltiplos registros | Separamos com vírgula: (reg1), (reg2), (reg3); — mais eficiente que 10 INSERTs separados |
| destaque = 1 | Espresso, Cappuccino, Latte, Pão de Queijo e Bolo de Cenoura estão marcados como destaque — vão aparecer na seção "Nossos Destaques" |
| Imagens Unsplash | O Unsplash oferece imagens gratuitas. O ?w=400 redimensiona para 400px de largura — carrega mais rápido |
| Colunas omitidas | Não informamos id (auto_increment), ativo (default 1), nem created_at (default agora) — o MySQL preenche sozinho |
Verifique se os dados foram inseridos:
SELECT nome, preco, categoria, destaque FROM menu_items;
cafeteria criado✅ Entendeu cada tipo de coluna (VARCHAR, DECIMAL, ENUM, TINYINT, TEXT, TIMESTAMP)
✅ Tabela
menu_items com 10 itens do cardápio✅ Tabela
contatos pronta para receber mensagens do formulário✅ Dados de teste inseridos e verificados com SELECT
O backend é o "garçom" entre o site e o banco de dados. O React (frontend) pede os dados do cardápio, o Express (backend) busca no MySQL e devolve.
S5.1 Criando o Projeto
No terminal do VS Code (conectado na VPS):
mkdir -p /root/cafeteria/backend
cd /root/cafeteria/backend
npm init -y
npm install express mysql2 cors dotenv
| Pacote | Para que serve |
|---|---|
express |
Framework web — cria rotas (GET, POST) e gerencia requisições |
mysql2 |
Driver para conectar ao MySQL com suporte a Promises |
cors |
Permite que o frontend (porta 5173) fale com o backend (porta 3737) |
dotenv |
Carrega variáveis do arquivo .env (senhas, configs) |
S5.2 Arquivo .env
Crie o arquivo /root/cafeteria/backend/.env:
PORT=3737
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=SuaSenhaForte123!
DB_NAME=cafeteria
.env contém senhas. Se publicar no GitHub, qualquer pessoa pode acessar seu banco. Adicione .env ao .gitignore.S5.3 server.js — O Coração do Backend
Crie /root/cafeteria/backend/server.js. Vamos construir trecho por trecho, explicando cada parte.
Passo 1 — Imports e configuração inicial
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const mysql = require('mysql2/promise');
| require('dotenv').config() | Lê o arquivo .env e carrega as variáveis (DB_HOST, DB_PASSWORD, etc.) para process.env |
| require('express') | Importa o framework Express — ele cria o servidor web e gerencia as rotas |
| require('cors') | Cross-Origin Resource Sharing — permite que o frontend (porta 5173) fale com o backend (porta 3737) |
| require('mysql2/promise') | Driver MySQL com suporte a async/await — o /promise é importante para usar await |
Passo 2 — Criando o app e ativando middlewares
const app = express();
app.use(cors());
app.use(express.json());
| express() | Cria a aplicação — é o "servidor" que vai ouvir as requisições |
| app.use(cors()) | Porteiro 1: permite requisições de outros domínios (sem isso, o React não consegue chamar a API) |
| app.use(express.json()) | Porteiro 2: converte o corpo da requisição de JSON para objeto JavaScript. Sem isso, req.body seria undefined |
Passo 3 — Conectando ao MySQL (Pool)
const pool = mysql.createPool({
host: process.env.DB_HOST, // localhost
user: process.env.DB_USER, // root
password: process.env.DB_PASSWORD, // do .env
database: process.env.DB_NAME, // cafeteria
waitForConnections: true,
connectionLimit: 10,
});
createPool e não createConnection?
| createConnection | Abre UMA conexão. Se cair, o server trava. Se 10 pessoas acessam ao mesmo tempo, esperam na fila. |
| createPool | Cria um grupo de 10 conexões reutilizáveis. Quando uma requisição termina, a conexão volta pro pool. Muito mais eficiente! |
| process.env.DB_HOST | Lê do arquivo .env. Assim a senha não fica exposta no código — fica num arquivo separado. |
Passo 4 — Rota de Health Check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
curl http://localhost:3737/health
2) Monitoramento: ferramentas como PM2 e Nginx podem chamar essa rota para verificar se o backend está saudável.
O app.get('/health', ...) significa: quando alguém acessar /health com método GET, execute essa função.Passo 5 — Rota GET /api/menu (listar cardápio)
app.get('/api/menu', async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT id, nome, descricao, preco, categoria, imagem_url, destaque FROM menu_items WHERE ativo = 1 ORDER BY categoria, nome'
);
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, error: 'Erro ao buscar cardápio' });
}
});
| async (req, res) | async permite usar await dentro da função. req = requisição (o que o cliente enviou), res = resposta (o que vamos devolver) |
| try { ... } catch | Se der erro no banco (ex: MySQL offline), o catch pega o erro e retorna 500 ao invés de travar o servidor |
| const [rows] = await | pool.query retorna [rows, fields]. Usamos desestruturação [rows] para pegar só os dados (ignoramos fields) |
| WHERE ativo = 1 | Filtra: só mostra itens com ativo = 1. Para "deletar" um item, basta fazer UPDATE SET ativo = 0 — é o conceito de soft delete |
| ORDER BY categoria, nome | Ordena primeiro por categoria (bebidas, cafes...) e depois por nome dentro de cada categoria |
| res.json({ success, data }) | Envia a resposta como JSON. O React vai receber isso com fetch('/api/menu') |
| res.status(500) | 500 = Internal Server Error. Códigos: 200 = OK, 201 = Criado, 400 = Erro do cliente, 500 = Erro do servidor |
Passo 6 — Rota GET /api/menu/destaques
app.get('/api/menu/destaques', async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT id, nome, descricao, preco, categoria, imagem_url FROM menu_items WHERE ativo = 1 AND destaque = 1'
);
res.json({ success: true, data: rows });
} catch (error) {
res.status(500).json({ success: false, error: 'Erro ao buscar destaques' });
}
});
AND destaque = 1. Só retorna os itens que foram marcados como destaque no banco — esses aparecem na seção "Nossos Destaques" da landing page.
Note que a estrutura é a mesma: try/catch + pool.query + res.json. Esse padrão se repete em toda rota de leitura.Passo 7 — Rota POST /api/contato (formulário)
app.post('/api/contato', async (req, res) => {
try {
const { nome, email, telefone, mensagem } = req.body;
req.body). O express.json() que ativamos no Passo 2 converte o JSON do corpo para um objeto JavaScript.
A desestruturação { nome, email, telefone, mensagem } = req.body extrai cada campo em uma variável separada. // Validação — campos obrigatórios
if (!nome || !email || !mensagem) {
return res.status(400).json({
success: false,
error: 'Nome, email e mensagem são obrigatórios',
});
}
required nos inputs), mas nunca confie só no frontend. Qualquer pessoa pode chamar sua API diretamente (via curl, Postman, etc.), pulando o formulário. A validação no backend é a última barreira.
O status 400 = Bad Request (erro do cliente — enviou dados incompletos). // Insere no banco com placeholders (?) contra SQL injection
await pool.query(
'INSERT INTO contatos (nome, email, telefone, mensagem) VALUES (?, ?, ?, ?)',
[nome, email, telefone || null, mensagem]
);
res.status(201).json({ success: true, message: 'Mensagem enviada com sucesso!' });
} catch (error) {
res.status(500).json({ success: false, error: 'Erro ao enviar mensagem' });
}
});
? (placeholders)?
NUNCA concatene variáveis direto no SQL assim: 'INSERT ... VALUES ("' + nome + '")'.
Um hacker pode enviar nome = "'; DROP TABLE contatos; --" e deletar sua tabela inteira (SQL Injection).
Os ? são substituídos pelo mysql2 de forma segura — ele escapa caracteres especiais automaticamente.
O telefone || null significa: se o telefone estiver vazio, insere NULL no banco.| 200 | OK — tudo certo (usado automaticamente pelo res.json()) |
| 201 | Created — algo foi criado no banco (usamos no POST de contato) |
| 400 | Bad Request — o cliente enviou dados inválidos ou incompletos |
| 500 | Internal Server Error — algo deu errado no servidor (banco offline, erro no SQL, etc.) |
Passo 8 — Iniciando o servidor
const PORT = process.env.PORT || 3737;
app.listen(PORT, () => {
console.log(`Servidor rodando na porta ${PORT}`);
});
process.env.PORT lê a porta do .env (3737). O || 3737 é um fallback: se o .env não tiver a variável, usa 3737.
app.listen(PORT, callback) inicia o servidor e fica "ouvindo" requisições na porta especificada. Quando estiver rodando, imprime a mensagem no terminal.server.js inteiro. Cole todos os trechos na ordem dentro de um único arquivo. O servidor tem: 2 middlewares (cors, json), 1 pool MySQL, 3 rotas (health, menu, contato) e 1 listen.S5.4 Testando o Backend
cd /root/cafeteria/backend
node server.js
# Em outro terminal (Ctrl+Shift+' no VS Code)
curl http://localhost:3737/health
curl http://localhost:3737/api/menu
curl http://localhost:3737/api/menu/destaques
http://localhost:3737/api/menu e clique Send. Vê os dados formatados com cores.Teste o formulário de contato:
curl -X POST http://localhost:3737/api/contato \
-H "Content-Type: application/json" \
-d '{"nome":"João","email":"joao@teste.com","mensagem":"Adorei o café!"}'
✅ 3 rotas funcionando:
/health, /api/menu, /api/contato✅ Dados do cardápio retornando do banco
✅ Formulário de contato salvando no banco
Agora vamos criar a parte visual — uma landing page profissional e bonita para a cafeteria. Vamos construir seção por seção, explicando cada componente, cada biblioteca e cada técnica usada.
| React | Biblioteca para criar interfaces — dividimos a tela em componentes reutilizáveis |
| TypeScript | JavaScript com tipagem — evita erros e melhora o autocompletar do VS Code |
| Tailwind CSS | Framework de estilos por classes utilitárias — estilo direto no HTML, sem criar CSS separado |
| Vite | Ferramenta de build ultrarrápida — cria o projeto, roda o dev server e faz o build para produção |
| Lucide React | Biblioteca de ícones SVG prontos — Coffee, Phone, Mail, etc. Mais de 1.500 ícones gratuitos |
S6.1 Criando o Projeto React
cd /root/cafeteria
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
--template react-ts — usa o template React com TypeScript (não JavaScript puro)
npm install — instala todas as dependências listadas no
package.jsonInstalando as Bibliotecas
# Tailwind CSS — estilização por classes utilitárias
npm install -D tailwindcss @tailwindcss/vite
# Lucide React — biblioteca de ícones SVG
npm install lucide-react
-D no Tailwind e não no Lucide?
O -D instala como dependência de desenvolvimento (devDependencies). O Tailwind só é usado no build — ele gera o CSS final e depois não é mais necessário.
Já o Lucide precisa estar no código em produção, porque os ícones são componentes React que rodam no navegador.Configurando o Vite
Edite vite.config.ts:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:3737', // redireciona /api para o backend
},
},
})
fetch('/api/menu'), o Vite intercepta e redireciona para http://localhost:3737/api/menu. Isso evita erros de CORS durante o desenvolvimento.Substitua o conteúdo de src/index.css:
@import "tailwindcss";
@import "tailwindcss" faz?
Importa as 3 camadas do Tailwind: base (reset CSS — remove estilos padrão do navegador), components (estilos de componentes) e utilities (as classes utilitárias como bg-red-500, flex, etc.).
Em produção, o Vite remove automaticamente todas as classes que você não usou — o CSS final fica pequeno.S6.2 Design — Paleta de Cores, Tipografia e Estrutura
Antes de escrever código, vamos entender os princípios de design que fazem uma landing page ser bonita e profissional.
Paleta de Cores
Toda landing page profissional usa uma paleta de cores consistente. Escolhemos cores que transmitam a identidade do negócio:
#1c1917
Fundo escuro
#44403c
Bordas
#fafaf9
Fundo claro
#d97706
Destaque/CTA
#fbbf24
Detalhes
| Psicologia das cores | Amber/dourado = calor, aconchego, sofisticação. Stone/marrom = terra, café, conforto. Cores quentes combinam com cafeteria. |
| Contraste | Texto claro em fundo escuro (ou vice-versa) — garante legibilidade. O amber-400 sobre stone-900 tem contraste 7:1 (excelente) |
| Cor de ação (CTA) | O botão "Ver Cardápio" é amber-600 — a cor mais vibrante da página, chama atenção para a ação principal |
| Regra 60-30-10 | 60% cor neutra (stone/branco), 30% cor secundária (stone escuro), 10% cor de destaque (amber). Proporção clássica do design. |
amber e stone por outras cores do Tailwind.
Exemplos: Clínica médica → blue + slate | Loja de plantas → green + stone | Tech/SaaS → violet + zinc | Restaurante → red + neutral.
O Tailwind oferece 22 paletas completas (50 a 950). Veja todas em tailwindcss.com/docs/colors.Tipografia — Hierarquia Visual
O tamanho e peso da fonte criam uma hierarquia que guia o olho do visitante:
| Classe Tailwind | Tamanho | Peso | Onde usamos |
|---|---|---|---|
text-5xl md:text-7xl font-bold | 72px | Bold (700) | Título principal do Hero — maior texto da página |
text-3xl font-bold | 30px | Bold (700) | Títulos de seção (Destaques, Cardápio, Sobre, Contato) |
text-xl font-bold | 20px | Bold (700) | Logo "Café Aroma" na navbar |
text-lg font-bold | 18px | Bold (700) | Nomes dos produtos nos cards |
text-sm | 14px | Normal (400) | Links, descrições, subtítulos |
text-xs uppercase tracking-[3px] | 12px | Normal | Labels de seção ("Favoritos da Casa", "Explore") |
| Hierarquia | Título > subtítulo > texto > detalhe. O visitante deve entender a estrutura sem ler — só pelo tamanho |
| Peso (font-weight) | font-bold (700) para títulos, font-semibold (600) para botões, font-medium (500) para labels |
| Espaçamento entre letras | tracking-[3px] estica as letras — usado em subtítulos UPPERCASE para um visual elegante |
| Altura da linha | leading-relaxed (1.625) para textos longos — mais espaço entre linhas facilita a leitura |
| Máximo 2-3 tamanhos | Não use 10 tamanhos diferentes. Mantenha consistência — mesma classe para mesma função em toda a página |
Entendendo as Classes do Tailwind
No Tailwind, cada classe faz uma coisa só. Vamos aprender a ler uma combinação complexa:
// Exemplo: botão CTA do Hero
className="inline-flex items-center gap-2 bg-amber-600 hover:bg-amber-700 text-white px-8 py-3.5 rounded-full text-sm font-semibold transition shadow-lg"
| inline-flex | Flexbox inline — coloca os filhos (texto + ícone) lado a lado |
| items-center | Alinha verticalmente no centro (ícone e texto na mesma linha) |
| gap-2 | Espaço de 8px entre texto e ícone (gap-1 = 4px, gap-2 = 8px, gap-4 = 16px) |
| bg-amber-600 | Cor de fundo amber intenso. Os números vão de 50 (claro) a 950 (escuro) |
| hover:bg-amber-700 | Ao passar o mouse, escurece para amber-700. O prefixo hover: aplica a classe só no hover |
| px-8 py-3.5 | Padding: 32px horizontal, 14px vertical. p = padding, x = eixo X, y = eixo Y |
| rounded-full | Bordas 100% arredondadas — transforma o retângulo em pílula |
| transition | Anima suavemente qualquer mudança (cor, tamanho). Sem isso, o hover seria instantâneo |
| shadow-lg | Sombra grande — dá sensação de "flutuação", destaca o botão do fundo |
1 = 4px | 2 = 8px | 3 = 12px | 4 = 16px | 6 = 24px | 8 = 32px | 10 = 40px | 12 = 48px | 16 = 64px | 20 = 80px
Funciona para: p (padding), m (margin), gap, w (width), h (height), spaceResponsividade — Mobile First
O Tailwind usa a abordagem mobile first: as classes sem prefixo valem para celular, e os prefixos (md:, lg:) adicionam regras para telas maiores:
| Prefixo | Largura mínima | Equivale a | Dispositivo |
|---|---|---|---|
| (nenhum) | 0px | Padrão | Celular |
sm: | 640px | @media (min-width: 640px) | Celular grande |
md: | 768px | @media (min-width: 768px) | Tablet |
lg: | 1024px | @media (min-width: 1024px) | Desktop |
xl: | 1280px | @media (min-width: 1280px) | Desktop grande |
Exemplo prático dos nossos cards de destaque:
// Grid que se adapta ao tamanho da tela
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"
// Leitura:
// Celular (< 768px): 1 coluna → cards empilhados
// Tablet (≥ 768px): 2 colunas → cards lado a lado
// Desktop (≥ 1024px): 4 colunas → todos visíveis
// Texto que muda de tamanho
className="text-5xl md:text-7xl font-bold"
// Celular: text-5xl (48px) — cabe na tela pequena
// Desktop: text-7xl (72px) — aproveita o espaço maior
| Sempre comece pelo celular | Escreva a classe sem prefixo (mobile), depois adicione md: e lg: para telas maiores |
| hidden md:flex | Esconde no celular, mostra no desktop. Usamos na navbar para esconder os links (em telas pequenas não cabem) |
| max-w-6xl mx-auto | Largura máxima de 1152px + centralizado. No celular ocupa 100%, no desktop fica centralizado com margem |
| px-6 | Padding lateral de 24px — evita que o texto encoste nas bordas da tela do celular |
Hover, Transições e Animações
Efeitos visuais dão feedback ao usuário e tornam a página viva. O Tailwind facilita com prefixos:
| Classe | O que faz | Onde usamos |
|---|---|---|
hover:bg-amber-700 | Muda a cor ao passar o mouse | Botões |
hover:shadow-xl | Aumenta a sombra — sensação de "levantar" | Cards |
hover:text-amber-400 | Muda cor do texto no hover | Links da navbar |
group-hover:scale-110 | Zoom de 110% quando o card pai recebe hover | Imagens dos destaques |
transition | Anima a mudança suavemente (150ms padrão) | Tudo que tem hover |
transition-all duration-300 | Anima tudo com duração de 300ms | Cards com múltiplos efeitos |
transition-transform duration-500 | Anima só o transform (zoom) em 500ms | Zoom lento da imagem |
group + group-hover?
Normalmente, hover: ativa quando você passa o mouse no próprio elemento. Mas queremos que, ao passar o mouse no card inteiro, a imagem dentro dele faça zoom.
Solução: coloque className="group" no card pai e className="group-hover:scale-110" na imagem filha. O Tailwind entende: "quando o grupo receber hover, aplique scale-110 neste filho".Anatomia de uma Landing Page Profissional
Uma LP eficiente segue uma estrutura psicológica que guia o visitante do interesse até a ação:
| Above the fold | O Hero é a primeira coisa que o visitante vê. Você tem 5 segundos para prender a atenção — por isso título grande + CTA claro |
| Destaques antes | Mostre o melhor primeiro. Se o visitante não rolar, pelo menos viu os produtos principais |
| Sobre = confiança | Estatísticas (5+ anos, 10k clientes) são prova social — convencem o visitante de que o negócio é sério |
| Contato por último | O visitante já conhece os produtos, a história e confia no negócio — agora está pronto para agir (enviar mensagem) |
| CTA em todo lugar | O botão "Ver Cardápio" no Hero e o formulário no final são chamadas para ação — sem CTA, o visitante só olha e vai embora |
Imagens — Unsplash e Otimização
| O que é Unsplash? | Banco de imagens gratuitas de alta qualidade — pode usar comercialmente sem pagar. Acesse: unsplash.com |
| Como buscar | Pesquise em inglês: "coffee shop", "cappuccino", "bakery". Clique na imagem → copie a URL |
| ?w=400 | Adicione ?w=400 ao final da URL para redimensionar para 400px. Economiza banda — não carregue fotos de 5000px num card de 200px |
| object-cover | Faz a imagem preencher o espaço sem distorcer — corta as bordas se necessário (como background-size: cover) |
| Em produção | Idealmente, hospede as imagens no seu próprio servidor ou use um CDN (CloudFlare, etc.) — não dependa do Unsplash para sempre |
✅ Criar hierarquia visual com tipografia (tamanhos, pesos, espaçamento)
✅ Ler e entender qualquer combinação de classes Tailwind
✅ Usar responsividade com prefixos mobile-first (md:, lg:)
✅ Aplicar hover, transições e animações com propósito
✅ Estruturar uma LP seguindo a jornada do visitante
✅ Encontrar e otimizar imagens profissionais gratuitas
S6.3 Estrutura do App.tsx — Imports e Estado
Apague todo o conteúdo de src/App.tsx. Vamos construir a landing page parte por parte. Comece com os imports e o estado:
// src/App.tsx — Parte 1: Imports e Estado
import { useState, useEffect } from 'react';
import { Coffee, Phone, Mail, MapPin, Clock, Send, ChevronDown, Instagram, Facebook } from 'lucide-react';
// Interface TypeScript — define o formato de um item do cardápio
interface MenuItem {
id: number;
nome: string;
descricao: string;
preco: number;
categoria: string;
imagem_url: string;
destaque: number;
}
export default function App() {
// Estado — dados que mudam e fazem a tela atualizar
const [menu, setMenu] = useState<MenuItem[]>([]); // todos os itens
const [destaques, setDestaques] = useState<MenuItem[]>([]); // itens em destaque
const [nome, setNome] = useState(''); // campos do formulário
const [email, setEmail] = useState('');
const [telefone, setTelefone] = useState('');
const [mensagem, setMensagem] = useState('');
const [enviado, setEnviado] = useState(false); // feedback de envio
const [filtro, setFiltro] = useState('todos'); // filtro de categoria
// useEffect — executa quando o componente carrega (1 vez)
useEffect(() => {
fetch('/api/menu').then(r => r.json()).then(d => setMenu(d.data));
fetch('/api/menu/destaques').then(r => r.json()).then(d => setDestaques(d.data));
}, []);
// Função para enviar o formulário de contato
const handleContato = async (e: React.FormEvent) => {
e.preventDefault();
await fetch('/api/contato', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nome, email, telefone, mensagem }),
});
setEnviado(true);
setNome(''); setEmail(''); setTelefone(''); setMensagem('');
setTimeout(() => setEnviado(false), 4000);
};
// Filtro de categorias — se 'todos', mostra tudo
const menuFiltrado = filtro === 'todos' ? menu : menu.filter(i => i.categoria === filtro);
return (
<div className="min-h-screen bg-stone-50">
| useState | Cria variáveis reativas — quando o valor muda, a tela atualiza automaticamente |
| useEffect | Executa código após o componente aparecer na tela — ideal para buscar dados da API |
| interface | Define o formato dos dados — o TypeScript avisa se você tentar acessar um campo que não existe |
| fetch | Faz requisições HTTP — busca dados do backend ou envia dados de formulário |
| preventDefault | Impede o formulário de recarregar a página — queremos enviar via JavaScript, não via HTML |
S6.4 Navbar — Barra de Navegação Fixa
A navbar fica fixa no topo com backdrop blur (efeito de vidro fosco). Os links são âncoras que rolam suavemente até cada seção:
{/* ===== NAVBAR — fixa no topo com efeito glassmorphism ===== */}
<nav className="fixed top-0 w-full bg-stone-900/95 backdrop-blur-sm z-50 border-b border-stone-800">
<div className="max-w-6xl mx-auto px-6 py-4 flex justify-between items-center">
<div className="flex items-center gap-2">
<Coffee className="text-amber-400" size={28} />
<span className="text-xl font-bold text-white">Café Aroma</span>
</div>
<div className="hidden md:flex gap-8 text-stone-300 text-sm">
<a href="#inicio" className="hover:text-amber-400 transition">Início</a>
<a href="#cardapio" className="hover:text-amber-400 transition">Cardápio</a>
<a href="#sobre" className="hover:text-amber-400 transition">Sobre</a>
<a href="#contato" className="hover:text-amber-400 transition">Contato</a>
</div>
</div>
</nav>
| fixed top-0 w-full | Fixa a navbar no topo e ocupa toda a largura |
| bg-stone-900/95 | Fundo escuro com 95% de opacidade — permite ver o conteúdo atrás |
| backdrop-blur-sm | Efeito glassmorphism — desfoque no fundo, visual moderno |
| z-50 | Z-index alto para a navbar ficar acima de todo o conteúdo |
| hidden md:flex | Esconde no celular, mostra no desktop — responsividade |
| hover:text-amber-400 | Muda a cor do link ao passar o mouse — feedback visual |
lucide-react fornece ícones como componentes React. Basta importar e usar: <Coffee size={28} />. O size define o tamanho em pixels. O className aplica cores via Tailwind.S6.5 Hero — Seção de Abertura Impactante
O hero ocupa a tela inteira com uma foto de fundo escurecida (overlay) e um CTA (Call to Action):
{/* ===== HERO — tela inteira com imagem de fundo e overlay escuro ===== */}
<section id="inicio" className="relative h-screen flex items-center justify-center text-center text-white">
<div className="absolute inset-0 bg-[url('https://images.unsplash.com/photo-1447933601403-0c6688de566e?w=1920')] bg-cover bg-center">
<div className="absolute inset-0 bg-stone-900/70" />
</div>
<div className="relative z-10 max-w-3xl px-6">
<p className="text-amber-400 text-sm tracking-[4px] uppercase mb-4">Bem-vindo ao</p>
<h1 className="text-5xl md:text-7xl font-bold mb-6 leading-tight">Café Aroma</h1>
<p className="text-lg md:text-xl text-stone-300 mb-10 max-w-xl mx-auto">
Cada xícara conta uma história. Grãos selecionados, torrados com carinho, servidos com paixão desde 2020.
</p>
<a href="#cardapio" className="inline-flex items-center gap-2 bg-amber-600 hover:bg-amber-700 text-white px-8 py-3.5 rounded-full text-sm font-semibold transition shadow-lg">
Ver Cardápio <ChevronDown size={18} />
</a>
</div>
</section>
| Imagem de fundo Unsplash | Usamos uma URL do Unsplash (banco de imagens gratuito) direto no Tailwind: bg-[url('...')] |
| Overlay escuro | bg-stone-900/70 — uma div escura com 70% de opacidade sobre a imagem, para o texto ficar legível |
| Posicionamento | absolute inset-0 estica a div para cobrir todo o pai. relative z-10 coloca o texto acima |
| Responsividade | text-5xl md:text-7xl — título menor no celular, maior no desktop |
| CTA (botão) | Botão arredondado (rounded-full) com sombra (shadow-lg) e efeito hover |
S6.6 Destaques — Cards com Hover Animado
Mostra os 4 produtos marcados como destaque no banco de dados. Cada card tem imagem com zoom no hover:
{/* ===== DESTAQUES — cards com imagem, zoom hover e sombra ===== */}
<section className="py-20 bg-white">
<div className="max-w-6xl mx-auto px-6">
<p className="text-amber-600 text-sm tracking-[3px] uppercase text-center mb-2">Favoritos da Casa</p>
<h2 className="text-3xl font-bold text-stone-800 text-center mb-12">Nossos Destaques</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{destaques.map(item => (
<div key={item.id} className="group bg-stone-50 rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300">
<div className="h-48 overflow-hidden">
<img src={item.imagem_url} alt={item.nome}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500" />
</div>
<div className="p-5">
<h3 className="font-bold text-stone-800 text-lg">{item.nome}</h3>
<p className="text-stone-500 text-sm mt-1 mb-3">{item.descricao}</p>
<p className="text-amber-600 font-bold text-lg">R$ {item.preco.toFixed(2)}</p>
</div>
</div>
))}
</div>
</div>
</section>
| Grid responsivo | grid-cols-1 md:grid-cols-2 lg:grid-cols-4 — 1 coluna no celular, 2 no tablet, 4 no desktop |
| group + group-hover | O Tailwind permite que, ao passar o mouse no card inteiro (group), a imagem dentro dele faça zoom (group-hover:scale-110) |
| overflow-hidden | A imagem faz zoom mas não "vaza" do card — o overflow-hidden corta o que sai |
| rounded-2xl | Bordas bem arredondadas — visual moderno tipo card do Instagram |
| shadow-sm → shadow-xl | Sombra sutil que aumenta no hover — dá sensação de "elevação" |
| object-cover | A imagem preenche todo o espaço sem distorcer — corta as bordas se necessário |
| toFixed(2) | Formata o preço com 2 casas decimais: 12.5 vira 12.50 |
destaques.map(item => ...) percorre o array e cria um card para cada item. O key={item.id} é obrigatório no React — ele usa a key para saber qual card atualizar se os dados mudarem.S6.7 Cardápio Completo — Filtro por Categoria
Seção com botões de filtro (todos, cafés, doces, salgados, bebidas) e cards horizontais com imagem lateral:
{/* ===== CARDÁPIO — filtro por categoria + cards horizontais ===== */}
<section id="cardapio" className="py-20 bg-stone-100">
<div className="max-w-6xl mx-auto px-6">
<p className="text-amber-600 text-sm tracking-[3px] uppercase text-center mb-2">Explore</p>
<h2 className="text-3xl font-bold text-stone-800 text-center mb-8">Cardápio Completo</h2>
{/* Botões de filtro */}
<div className="flex justify-center gap-3 mb-10 flex-wrap">
{['todos', 'cafes', 'doces', 'salgados', 'bebidas'].map(cat => (
<button key={cat} onClick={() => setFiltro(cat)}
className={`px-5 py-2 rounded-full text-sm font-medium transition ${
filtro === cat
? 'bg-amber-600 text-white'
: 'bg-white text-stone-600 hover:bg-stone-200'
}`}>
{cat === 'todos' ? 'Todos' : cat.charAt(0).toUpperCase() + cat.slice(1)}
</button>
))}
</div>
{/* Grid de cards do cardápio */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{menuFiltrado.map(item => (
<div key={item.id} className="flex gap-4 bg-white rounded-xl p-4 shadow-sm hover:shadow-md transition">
<img src={item.imagem_url} alt={item.nome}
className="w-24 h-24 rounded-lg object-cover flex-shrink-0" />
<div>
<h3 className="font-bold text-stone-800">{item.nome}</h3>
<p className="text-stone-500 text-sm mt-0.5">{item.descricao}</p>
<p className="text-amber-600 font-bold mt-2">R$ {item.preco.toFixed(2)}</p>
</div>
</div>
))}
</div>
</div>
</section>
Estado filtro | Começa como 'todos'. Quando clica em "Doces", muda para 'doces' |
| menuFiltrado | Se filtro === 'todos', mostra tudo. Senão, .filter() mantém só os itens daquela categoria |
| Classe condicional | O botão ativo fica laranja (bg-amber-600), os outros ficam brancos — usando template literal com ${...} |
| Atualização automática | Ao clicar no filtro, o React re-renderiza automaticamente — mostra só os cards filtrados, sem recarregar a página |
flex gap-4 coloca imagem e texto lado a lado. flex-shrink-0 na imagem impede que ela encolha quando o texto é grande. O resultado é um card compacto e elegante.S6.8 Sobre — Layout Dois Colunas com Estatísticas
Seção com foto à esquerda e texto + números à direita. Layout de duas colunas usando grid:
{/* ===== SOBRE — imagem + texto lado a lado com estatísticas ===== */}
<section id="sobre" className="py-20 bg-white">
<div className="max-w-6xl mx-auto px-6 grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<div>
<img src="https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=600" alt="Interior"
className="rounded-2xl shadow-lg w-full h-80 object-cover" />
</div>
<div>
<p className="text-amber-600 text-sm tracking-[3px] uppercase mb-2">Nossa História</p>
<h2 className="text-3xl font-bold text-stone-800 mb-6">Paixão em cada grão</h2>
<p className="text-stone-600 mb-4 leading-relaxed">
O Café Aroma nasceu em 2020, no coração da cidade, com um sonho simples: servir o melhor café
que você já provou. Selecionamos grãos de pequenos produtores brasileiros, torramos artesanalmente
e preparamos cada xícara com o cuidado que ela merece.
</p>
<p className="text-stone-600 mb-6 leading-relaxed">
Nosso espaço foi pensado para ser seu refúgio — venha trabalhar, estudar, encontrar amigos ou
simplesmente apreciar um bom café em paz.
</p>
<div className="flex gap-8">
<div>
<p className="text-3xl font-bold text-amber-600">5+</p>
<p className="text-stone-500 text-sm">Anos de história</p>
</div>
<div>
<p className="text-3xl font-bold text-amber-600">10k+</p>
<p className="text-stone-500 text-sm">Clientes felizes</p>
</div>
<div>
<p className="text-3xl font-bold text-amber-600">50k+</p>
<p className="text-stone-500 text-sm">Cafés servidos</p>
</div>
</div>
</div>
</div>
</section>
| Grid 2 colunas | grid-cols-1 md:grid-cols-2 — empilha no celular, lado a lado no desktop |
| items-center | Alinha verticalmente imagem e texto — a imagem pode ter altura diferente do texto |
| tracking-[3px] | Espaçamento entre letras — usado no subtítulo "Nossa História" para um visual elegante |
| Estatísticas | 3 blocos com números grandes em amber + descrição pequena — padrão de landing pages profissionais |
S6.9 Contato — Formulário Funcional + Cards de Info
Esta seção é dividida em duas colunas: informações de contato à esquerda e formulário à direita. Vamos construir cada parte:
Parte 1 — Abertura da seção e layout em grid
{/* ===== CONTATO — fundo escuro, 2 colunas ===== */}
<section id="contato" className="py-20 bg-stone-900 text-white">
<div className="max-w-6xl mx-auto px-6 grid grid-cols-1 md:grid-cols-2 gap-12">
grid grid-cols-1 md:grid-cols-2 — no celular fica uma coluna (info em cima, formulário embaixo). No desktop fica lado a lado. O gap-12 dá espaçamento entre as colunas.Parte 2 — Cards de informações de contato (coluna esquerda)
{/* Lado esquerdo — informações de contato */}
<div>
<p className="text-amber-400 text-sm tracking-[3px] uppercase mb-2">Fale Conosco</p>
<h2 className="text-3xl font-bold mb-8">Entre em Contato</h2>
<div className="space-y-6">
{/* Card de informação — cada um segue o mesmo padrão */}
<div className="flex items-center gap-4">
<div className="bg-amber-600/20 p-3 rounded-full">
<MapPin className="text-amber-400" size={22} />
</div>
<div>
<p className="font-medium">Endereço</p>
<p className="text-stone-400 text-sm">Rua do Café, 123 — Centro</p>
</div>
</div>
{/* Repita o mesmo padrão para os outros 3: */}
<div className="flex items-center gap-4">
<div className="bg-amber-600/20 p-3 rounded-full"><Phone className="text-amber-400" size={22} /></div>
<div><p className="font-medium">Telefone</p><p className="text-stone-400 text-sm">(22) 99999-0000</p></div>
</div>
<div className="flex items-center gap-4">
<div className="bg-amber-600/20 p-3 rounded-full"><Clock className="text-amber-400" size={22} /></div>
<div><p className="font-medium">Horário</p><p className="text-stone-400 text-sm">Seg-Sáb: 7h–20h | Dom: 8h–14h</p></div>
</div>
<div className="flex items-center gap-4">
<div className="bg-amber-600/20 p-3 rounded-full"><Mail className="text-amber-400" size={22} /></div>
<div><p className="font-medium">Email</p><p className="text-stone-400 text-sm">contato@cafearoma.com.br</p></div>
</div>
</div>
</div>
| flex items-center gap-4 | Coloca ícone e texto lado a lado, centralizados verticalmente |
| bg-amber-600/20 | Fundo amber com 20% de opacidade — cria um círculo translúcido atrás do ícone |
| p-3 rounded-full | Padding + bordas 100% arredondadas = círculo perfeito em volta do ícone |
| space-y-6 | Espaçamento vertical entre cada card — sem precisar de margin em cada um |
| Padrão repetível | Os 4 cards seguem a mesma estrutura — só muda o ícone (MapPin, Phone, Clock, Mail) e o texto |
Parte 3 — Formulário funcional (coluna direita)
{/* Lado direito — formulário */}
<div>
{enviado ? (
{/* Mensagem de sucesso (aparece por 4 segundos) */}
<div className="bg-green-600/20 border border-green-500/30 rounded-2xl p-8 text-center">
<p className="text-green-400 text-xl font-bold mb-2">Mensagem enviada!</p>
<p className="text-stone-400">Retornaremos em breve.</p>
</div>
) : (
{/* Formulário — cada input é um controlled component */}
<form onSubmit={handleContato} className="space-y-4">
<input value={nome} onChange={e => setNome(e.target.value)} required
placeholder="Seu nome"
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-5 py-3 text-white placeholder-stone-500 focus:border-amber-500 focus:outline-none" />
<input value={email} onChange={e => setEmail(e.target.value)} required type="email"
placeholder="Seu email"
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-5 py-3 text-white placeholder-stone-500 focus:border-amber-500 focus:outline-none" />
<input value={telefone} onChange={e => setTelefone(e.target.value)}
placeholder="Telefone (opcional)"
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-5 py-3 text-white placeholder-stone-500 focus:border-amber-500 focus:outline-none" />
<textarea value={mensagem} onChange={e => setMensagem(e.target.value)} required rows={4}
placeholder="Sua mensagem..."
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-5 py-3 text-white placeholder-stone-500 focus:border-amber-500 focus:outline-none resize-none" />
<button type="submit"
className="w-full bg-amber-600 hover:bg-amber-700 text-white py-3.5 rounded-xl font-semibold flex items-center justify-center gap-2 transition">
Enviar Mensagem <Send size={18} />
</button>
</form>
)}
</div>
</div>
</section>
| {enviado ? (...) : (...)} | Renderização condicional: se enviado é true, mostra a mensagem de sucesso. Se false, mostra o formulário |
| value={nome} | O valor do input vem do estado — é o que chamamos de controlled component |
| onChange={e => setNome(...)} | Cada tecla digitada atualiza o estado — o React re-renderiza o input com o novo valor |
| required | Validação HTML nativa — o navegador bloqueia o envio se o campo estiver vazio |
| onSubmit={handleContato} | Quando clica "Enviar" (ou aperta Enter), chama a função que faz fetch POST /api/contato |
| focus:border-amber-500 | Quando clica no input, a borda fica amber — feedback visual de qual campo está ativo |
| resize-none | No textarea, impede que o usuário redimensione a caixa de texto — mantém o layout limpo |
handleContato faz fetch POST /api/contato →
3) Backend valida e faz INSERT INTO contatos →
4) Frontend mostra "Mensagem enviada!" por 4s →
5) Formulário reaparece limpo (campos resetados)S6.10 Footer — Rodapé com Redes Sociais
Rodapé simples e elegante com logo, copyright e ícones de redes sociais:
{/* ===== FOOTER — logo, copyright e redes sociais ===== */}
<footer className="bg-stone-950 text-stone-500 py-10">
<div className="max-w-6xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-2">
<Coffee className="text-amber-400" size={20} />
<span className="text-white font-bold">Café Aroma</span>
</div>
<p className="text-sm">© 2026 Café Aroma. Todos os direitos reservados.</p>
<div className="flex gap-4">
<a href="#" className="hover:text-amber-400 transition"><Instagram size={20} /></a>
<a href="#" className="hover:text-amber-400 transition"><Facebook size={20} /></a>
</div>
</div>
</footer>
</div> {/* fecha min-h-screen */}
);
}
| 1 | Navbar | Fixa no topo, glassmorphism, links âncora, ícone Coffee |
| 2 | Hero | Fullscreen, foto Unsplash com overlay, CTA "Ver Cardápio" |
| 3 | Destaques | 4 cards com zoom hover, dados do banco (destaque=1) |
| 4 | Cardápio | Filtro por categoria + cards horizontais com imagem lateral |
| 5 | Sobre | 2 colunas: foto + texto + estatísticas (anos, clientes, cafés) |
| 6 | Contato | Cards com ícones + formulário funcional (salva no MySQL) |
| 7 | Footer | Logo, copyright, ícones de redes sociais |
App.tsx. Cole tudo na ordem dentro de um só arquivo. Comece pelos imports (S6.3), depois o return com Navbar (S6.4), Hero (S6.5), Destaques (S6.6), Cardápio (S6.7), Sobre (S6.8), Contato (S6.9) e Footer (S6.10). Feche o componente com }.S6.11 Testando Localmente
# Terminal 1 — Backend
cd /root/cafeteria/backend
node server.js
# Terminal 2 — Frontend
cd /root/cafeteria/frontend
npm run dev -- --host 0.0.0.0
Acesse http://SEU_IP:5173 no navegador. Você deve ver a landing page com o cardápio carregado do banco.
--host 0.0.0.0 é necessário na VPS
Por padrão, o Vite só aceita conexões de localhost. Na VPS, você está acessando de fora — o --host 0.0.0.0 libera acesso externo. Em produção isso não importa porque usamos npm run build.ufw allow 5173. Depois do teste, remova: ufw delete allow 5173.✅ Entendeu paleta de cores, tipografia, responsividade e estrutura de LP (S6.2)
✅ Aprendeu a ler classes Tailwind e escala de espaçamento (S6.2)
✅ Entendeu useState, useEffect, interface e fetch (S6.3)
✅ Navbar fixa com glassmorphism e links âncora (S6.4)
✅ Hero fullscreen com imagem de fundo e CTA (S6.5)
✅ Cards de destaque com zoom hover e dados do banco (S6.6)
✅ Cardápio com filtro por categoria — atualização automática (S6.7)
✅ Seção Sobre com 2 colunas e estatísticas (S6.8)
✅ Formulário de contato funcional salvando no MySQL (S6.9)
✅ Footer com copyright e redes sociais (S6.10)
✅ Testou localmente com backend + frontend rodando (S6.11)
S6.12 Personalizando para Seu Negócio
Essa landing page foi construída para uma cafeteria, mas a mesma estrutura serve para qualquer negócio. Aqui está como adaptar:
Trocando as Cores (2 minutos)
Basta trocar amber e stone por outras paletas do Tailwind em todo o App.tsx:
| Negócio | Cor principal | Cor de fundo | Visual |
|---|---|---|---|
| Clínica / Dentista | blue | slate | Profissional, confiança |
| Loja de Plantas | green | stone | Natural, orgânico |
| Barbearia | orange | zinc | Masculino, urbano |
| Tech / SaaS | violet | zinc | Moderno, inovador |
| Restaurante | red | neutral | Apetitoso, quente |
| Pet Shop | teal | slate | Carinhoso, limpo |
| Academia | lime | zinc | Energia, saúde |
amber por blue (ou a cor desejada)
2) Troque stone por slate (ou a cor desejada)
Clique "Replace All" — pronto, toda a paleta mudou em segundos.Adaptando o Conteúdo
| Seção | O que mudar |
|---|---|
| Navbar | Troque o ícone <Coffee /> por outro do Lucide (ex: <Scissors /> para barbearia, <Heart /> para clínica) |
| Hero | Troque a imagem de fundo (Unsplash), o título e o texto. Mude o CTA para sua ação principal |
| Destaques | Seus 3-4 produtos/serviços mais vendidos. Troque nomes, descrições e imagens |
| Cardápio | Renomeie para "Serviços", "Produtos" ou "Planos". Mude as categorias no ENUM do banco |
| Sobre | Sua história real. Troque números e a foto do interior do negócio |
| Contato | Seu endereço, telefone, email e horário reais |
Adaptando o Banco de Dados
Para outro tipo de negócio, renomeie a tabela e mude as categorias:
-- Exemplo: Barbearia
CREATE TABLE servicos (
id INT AUTO_INCREMENT PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
descricao VARCHAR(255),
preco DECIMAL(10,2) NOT NULL,
categoria ENUM('cortes', 'barba', 'combos', 'tratamentos') NOT NULL,
imagem_url VARCHAR(500),
destaque TINYINT(1) DEFAULT 0,
ativo TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
server.js (menu_items → servicos)
2) A interface MenuItem no App.tsx (renomeie para Servico)
3) As categorias no array de filtro: ['todos', 'cortes', 'barba', ...]
4) Os textos de cada seção para refletir o novo negócio✅ Troque o ícone da Navbar (Lucide tem 1.500+ ícones)
✅ Troque imagens (Unsplash — pesquise o nicho)
✅ Troque textos (título, descrição, história, contato)
✅ Adapte o banco (categorias, nome da tabela)
✅ A estrutura da LP (Hero → Destaques → Catálogo → Sobre → Contato) funciona para qualquer tipo de negócio
O site está funcionando com npm run dev, mas isso é só para desenvolvimento. Para produção, precisamos fazer o build (gerar arquivos estáticos) e configurar o Nginx para servir o site.
S7.1 Build do Frontend
cd /root/cafeteria/frontend
npm run build
Isso cria a pasta dist/ com arquivos HTML, CSS e JS minificados — prontos para servir.
npm run build transforma tudo em HTML/CSS/JS puros, minificados (menores) e otimizados. O resultado fica na pasta dist/.S7.2 Subindo o Backend com PM2
cd /root/cafeteria/backend
pm2 start server.js --name cafeteria-api
pm2 save
pm2 startup
| Comando | O que faz |
|---|---|
pm2 start server.js --name cafeteria-api |
Inicia o backend com o nome "cafeteria-api" |
pm2 save |
Salva a lista de processos — será restaurada após reboot |
pm2 startup |
Configura o PM2 para iniciar automaticamente quando o servidor ligar |
pm2 list — ver processos rodandopm2 logs cafeteria-api — ver logs em tempo realpm2 restart cafeteria-api — reiniciar o backendpm2 stop cafeteria-api — parar temporariamenteS7.3 Configurando o Nginx
Agora vamos dizer ao Nginx: "sirva os arquivos do React e redirecione as chamadas /api para o Node.js".
Crie o arquivo de configuração:
nano /etc/nginx/sites-available/cafeteria
/etc/nginx/sites-available/ na barra lateral e criar o arquivo clicando com o botão direito → New File.Vamos construir o arquivo bloco por bloco, explicando cada diretiva:
Bloco 1 — Abertura do server e configuração básica
server {
listen 80;
server_name SEU_IP; # Depois troque pelo domínio
root /root/cafeteria/frontend/dist;
index index.html;
}
| server { ... } | Bloco que define UM site. Você pode ter vários server {} no Nginx (um para cada domínio) |
| listen 80 | Escuta na porta 80 (HTTP). Quando configurarmos SSL, o Certbot vai adicionar a porta 443 (HTTPS) automaticamente |
| server_name SEU_IP | O IP ou domínio que esse bloco atende. Troque por seu IP real agora — depois, pelo domínio |
| root /root/.../dist | Pasta raiz do site — é onde estão os arquivos gerados pelo npm run build do React |
| index index.html | Quando acessar /, o Nginx serve o index.html — que é a entrada do React |
Bloco 2 — Proxy reverso para a API (Node.js)
# Quando acessar /api/* → redireciona para o Node.js
location /api/ {
proxy_pass http://127.0.0.1:3737/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
| location /api/ { ... } | Captura TODAS as URLs que começam com /api/ (ex: /api/menu, /api/contato) |
| proxy_pass http://127.0.0.1:3737 | Proxy reverso — o Nginx recebe a requisição e repassa para o Node.js na porta 3737. O usuário nunca acessa a 3737 diretamente |
| proxy_set_header Host | Envia o nome do domínio original para o Node.js (útil se tiver vários sites no mesmo servidor) |
| X-Real-IP | Envia o IP real do visitante. Sem isso, o Node.js veria sempre 127.0.0.1 (o IP do Nginx) |
| X-Forwarded-Proto | Informa se a requisição original era HTTP ou HTTPS — importante para redirecionamentos |
Bloco 3 — SPA fallback (obrigatório para React)
# Qualquer outra URL → devolve o index.html do React
location / {
try_files $uri $uri/ /index.html;
}
/cardapio, o Nginx procura um arquivo chamado cardapio no disco — que não existe! (é uma rota do React, não um arquivo real).
O try_files resolve assim:
1) Tenta $uri — existe o arquivo /cardapio? Não.
2) Tenta $uri/ — existe a pasta /cardapio/? Não.
3) Fallback: serve /index.html — o React carrega e o React Router cuida da rota.
Sem essa linha, qualquer URL que não seja / daria erro 404!Bloco 4 — Cache para arquivos estáticos
# Imagens, CSS e JS ficam em cache por 30 dias
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
| location ~* \.(js|css|...)$ | O ~* é regex (case-insensitive). Captura qualquer arquivo terminado em .js, .css, .png, etc. |
| expires 30d | O navegador guarda esses arquivos por 30 dias — na próxima visita, não precisa baixar de novo |
| immutable | Diz ao navegador: "este arquivo nunca vai mudar". O Vite gera nomes com hash (app-abc123.js), então se o código mudar, o nome muda |
/etc/nginx/sites-available/cafeteria. Lembre de trocar SEU_IP pelo IP real da sua VPS. O resultado final deve ter: server { ... } com os 4 blocos location dentro.Ativando o site
# Cria o link simbólico (ativa o site)
ln -s /etc/nginx/sites-available/cafeteria /etc/nginx/sites-enabled/
# Remove o site padrão do Nginx (aquele "Welcome to nginx")
rm /etc/nginx/sites-enabled/default
# Testa a configuração (deve mostrar "syntax is ok")
nginx -t
# Recarrega o Nginx
systemctl reload nginx
ln -s?
Cria um atalho (link simbólico). O arquivo real fica em sites-available/, e o link em sites-enabled/ aponta para ele. O Nginx só lê o que está em sites-enabled/. Para desativar um site, basta deletar o link.Agora acesse http://SEU_IP no navegador. O site da cafeteria deve aparecer!
npm run build → pasta dist/)✅ Backend rodando com PM2 (24/7 + reinício automático)
✅ Nginx configurado — proxy reverso + arquivos estáticos
✅ Site acessível via
http://SEU_IP no navegador✅ Entendeu o fluxo: Navegador → Nginx → React (estático) OU Node.js (API) → MySQL
O site está no ar, mas só com IP. Vamos colocar um domínio bonito e HTTPS (cadeado verde).
S8.1 Registrando um Domínio
Hostinger — domínios .com (às vezes vem grátis com a VPS)
Namecheap — domínios internacionais baratos
Exemplo:
cafearoma.com.br ou cafearoma.comS8.2 Configurando o DNS
No painel do provedor de domínio, crie um registro tipo A:
| Campo | Valor |
|---|---|
| Tipo | A |
| Nome/Host | @ (ou vazio — significa o domínio principal) |
| Valor/Aponta para | 154.62.109.73 (o IP da sua VPS) |
| TTL | 300 (ou o menor disponível) |
Atualizando o Nginx com o domínio
Edite o arquivo /etc/nginx/sites-available/cafeteria e troque server_name:
server_name cafearoma.com.br;
nginx -t && systemctl reload nginx
S8.3 Instalando SSL (HTTPS) com Let's Encrypt
# Instala o Certbot
apt install -y certbot python3-certbot-nginx
# Gera o certificado SSL (gratuito!)
certbot --nginx -d cafearoma.com.br
O Certbot vai:
- Verificar que o domínio aponta para seu IP
- Gerar o certificado SSL
- Configurar o Nginx automaticamente para usar HTTPS
- Redirecionar HTTP → HTTPS
certbot renew --dry-runTeste acessando https://cafearoma.com.br — deve aparecer o cadeado verde e o site completo.
✅ Contratou e acessou uma VPS na Hostinger
✅ Usou o Remote SSH para editar arquivos do servidor direto no VS Code
✅ Instalou Node.js, MySQL, Nginx, PM2, UFW no servidor
✅ Criou um banco de dados com cardápio e formulário de contato
✅ Construiu uma API Express com 3 rotas funcionais
✅ Criou uma landing page profissional com React + Tailwind (6 seções)
✅ Fez o build e configurou o PM2 para manter o backend rodando 24/7
✅ Configurou o Nginx como proxy reverso — entendeu cada diretiva
✅ Registrou domínio e configurou DNS
✅ Instalou SSL/HTTPS gratuito com Let's Encrypt
✅ Entendeu o fluxo completo: domínio → DNS → Nginx → React/Node.js → MySQL
Parabéns! Você saiu do zero e colocou um site profissional no ar — com banco de dados, HTTPS e domínio próprio. A mesma base serve para qualquer projeto: mude o conteúdo, adapte o design, e lance seu próximo site.