feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s

- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
  preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
  keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint

migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
            i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
MatheusAlves96 2026-04-22 22:35:17 -03:00
parent 6ef5a7a17e
commit cf5603243c
106 changed files with 11927 additions and 1367 deletions

View file

@ -0,0 +1,404 @@
# Tasks: Feature 027 — Configuração da Página de Contato (Admin)
**Branch**: `027-config-pagina-contato`
**Spec**: `specs/027-config-pagina-contato/spec.md`
**Última migration**: `g1h2i3j4k5l6_add_source_to_contact_leads.py`
---
## Fase 1 — Foundational: Backend Core (Pré-requisito para todos os user stories)
> **Objetivo**: Criar a tabela `contact_config`, o modelo ORM, os schemas Pydantic e o
> endpoint público de leitura. Nenhum user story pode ser implementado antes desta fase.
>
> **⚠️ CRÍTICO**: Concluir inteiramente antes de iniciar as fases 2 e 3.
- [ ] T001 Gerar migration Alembic para criar tabela `contact_config` com INSERT inicial em `backend/migrations/versions/h2i3j4k5l6m7_add_contact_config.py`
**Comando para gerar a migration** (executar de dentro do container ou com `.venv`):
```bash
flask --app run:app db revision --autogenerate -m "add_contact_config"
```
A migration deve:
1. Criar a tabela com os campos abaixo:
```python
op.create_table(
"contact_config",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("address_street", sa.String(200), nullable=False),
sa.Column("address_neighborhood_city", sa.String(200), nullable=False),
sa.Column("address_zip", sa.String(20), nullable=False),
sa.Column("phone", sa.String(30), nullable=False),
sa.Column("email", sa.String(254), nullable=False),
sa.Column("business_hours", sa.Text, nullable=False),
sa.Column("updated_at", sa.DateTime, nullable=False,
server_default=sa.func.now()),
)
```
2. Inserir o registro singleton com os valores atualmente hardcoded:
```python
op.execute("""
INSERT INTO contact_config
(id, address_street, address_neighborhood_city, address_zip,
phone, email, business_hours, updated_at)
VALUES
(1, 'Rua das Imobiliárias, 123',
'Centro — São Paulo, SP',
'CEP 01000-000',
'(11) 99999-0000',
'contato@imobiliariahub.com.br',
'Segunda a Sexta: 9h às 18h\nSábado: 9h às 13h',
NOW())
""")
```
- [ ] T002 Criar modelo `ContactConfig` em `backend/app/models/contact_config.py` seguindo o padrão de `HomepageConfig`
```python
from app.extensions import db
class ContactConfig(db.Model):
__tablename__ = "contact_config"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
address_street = db.Column(db.String(200), nullable=False)
address_neighborhood_city = db.Column(db.String(200), nullable=False)
address_zip = db.Column(db.String(20), nullable=False)
phone = db.Column(db.String(30), nullable=False)
email = db.Column(db.String(254), nullable=False)
business_hours = db.Column(db.Text, nullable=False)
updated_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(),
onupdate=db.func.now(),
)
def __repr__(self) -> str:
return f"<ContactConfig id={self.id!r}>"
```
- [ ] T003 [P] Criar schemas Pydantic em `backend/app/schemas/contact_config.py` seguindo o padrão de `HomepageConfigOut`/`HomepageConfigIn`
```python
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr, field_validator
class ContactConfigOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
address_street: str
address_neighborhood_city: str
address_zip: str
phone: str
email: str
business_hours: str
updated_at: datetime
class ContactConfigIn(BaseModel):
address_street: str
address_neighborhood_city: str
address_zip: str
phone: str
email: EmailStr
business_hours: str
@field_validator("address_street", "address_neighborhood_city", "address_zip",
"phone", "business_hours")
@classmethod
def not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("Campo não pode ser vazio")
return v
```
- [ ] T004 Criar rota pública `GET /api/v1/contact-config` em `backend/app/routes/contact_config.py` e registrar o blueprint em `backend/app/__init__.py`
**`backend/app/routes/contact_config.py`**:
```python
from flask import Blueprint, jsonify
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigOut
contact_config_bp = Blueprint("contact_config", __name__, url_prefix="/api/v1")
@contact_config_bp.get("/contact-config")
def get_contact_config():
config = ContactConfig.query.first()
if config is None:
return jsonify({"error": "Contact config not found"}), 404
return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json"))
```
**`backend/app/__init__.py`** — adicionar após o import de `homepage_bp`:
```python
from app.routes.contact_config import contact_config_bp
```
E registrar junto aos demais blueprints:
```python
app.register_blueprint(contact_config_bp)
```
**Checkpoint — Fase 1 concluída**: `GET /api/v1/contact-config` retorna os dados do banco. As fases 2 e 3 podem ser iniciadas em paralelo.
---
## Fase 2 — User Stories 1 + 3: Admin Edita Configuração e Endpoint Protegido (P1)
> **Objetivo**: Administrador acessa `/admin/contact-config`, vê o formulário preenchido
> com os valores atuais, edita e salva. O endpoint PUT rejeita acessos não autorizados.
>
> **Teste independente**: Autenticar como admin, acessar `/admin/contact-config`,
> alterar o telefone, clicar em "Salvar". Verificar `GET /api/v1/contact-config` retorna o
> novo valor. Verificar que `PUT /api/v1/admin/contact-config` sem token retorna HTTP 401.
### Implementação — User Stories 1 + 3
- [ ] T005 [US1] Adicionar rota `PUT /api/v1/admin/contact-config` em `backend/app/routes/admin.py` com `@require_admin`
**Adicionar imports** no topo de `backend/app/routes/admin.py`:
```python
from app.models.contact_config import ContactConfig
from app.schemas.contact_config import ContactConfigIn, ContactConfigOut
```
**Adicionar a rota** (em qualquer ponto lógico do arquivo, ex.: próximo a outras rotas de configuração):
```python
@admin_bp.put("/contact-config")
@require_admin
def update_contact_config():
try:
data = ContactConfigIn.model_validate(request.get_json(force=True) or {})
except ValidationError as exc:
return jsonify({"errors": exc.errors()}), 422
config = ContactConfig.query.first()
if config is None:
config = ContactConfig(id=1, **data.model_dump())
db.session.add(config)
else:
for field, value in data.model_dump().items():
setattr(config, field, value)
db.session.commit()
db.session.refresh(config)
return jsonify(ContactConfigOut.model_validate(config).model_dump(mode="json"))
```
> `@require_admin` garante HTTP 401 para não-autenticados e HTTP 403 para não-admins (US3).
- [ ] T006 [P] [US1] Criar `frontend/src/services/contactConfig.ts` com `getContactConfig()` e `updateContactConfig()`
```typescript
import { api } from './api'
export interface ContactConfig {
address_street: string
address_neighborhood_city: string
address_zip: string
phone: string
email: string
business_hours: string
updated_at: string
}
export interface ContactConfigInput {
address_street: string
address_neighborhood_city: string
address_zip: string
phone: string
email: string
business_hours: string
}
export async function getContactConfig(): Promise<ContactConfig> {
const response = await api.get<ContactConfig>('/contact-config')
return response.data
}
export async function updateContactConfig(data: ContactConfigInput): Promise<ContactConfig> {
const response = await api.put<ContactConfig>('/admin/contact-config', data)
return response.data
}
```
- [ ] T007 [US1] Criar `frontend/src/pages/admin/AdminContactConfigPage.tsx` seguindo o padrão visual das demais páginas admin (ex.: `AdminAgentsPage.tsx`)
**Comportamento esperado**:
- `useEffect` faz `GET /api/v1/contact-config` ao montar e pré-preenche o form
- Estado local `form` com os 6 campos editáveis
- `handleSubmit` chama `updateContactConfig(form)`, exibe toast de sucesso ou erro
- Botão "Salvar" desabilitado enquanto `saving === true` (FR-019)
- Validação frontend: e-mail com formato válido, campos não vazios antes de submeter (FR-017)
- Erros de campo exibidos inline; toast global para erros de rede
**Estrutura do componente**:
```tsx
import { useState, useEffect } from 'react'
import { getContactConfig, updateContactConfig } from '../../services/contactConfig'
import type { ContactConfigInput } from '../../services/contactConfig'
const emptyForm: ContactConfigInput = {
address_street: '',
address_neighborhood_city: '',
address_zip: '',
phone: '',
email: '',
business_hours: '',
}
export default function AdminContactConfigPage() {
const [form, setForm] = useState<ContactConfigInput>(emptyForm)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getContactConfig()
.then(data => {
const { updated_at, ...editable } = data
setForm(editable)
})
.finally(() => setLoading(false))
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
setError(null)
setSuccess(false)
try {
await updateContactConfig(form)
setSuccess(true)
} catch {
setError('Erro ao salvar. Tente novamente.')
} finally {
setSaving(false)
}
}
// Renderizar: loading skeleton → formulário com 5 inputs + 1 textarea + botão salvar
// Campos: Logradouro, Bairro/Cidade, CEP, Telefone, E-mail, Horário de Atendimento
}
```
- [ ] T008 [US1] Registrar rota `/admin/contact-config` em `frontend/src/App.tsx` e adicionar item `{ to: '/admin/contact-config', label: 'Conf. Contato' }` em `adminNavItems` em `frontend/src/components/Navbar.tsx`
**`frontend/src/App.tsx`** — localizar o trecho de rotas admin e adicionar:
```tsx
import AdminContactConfigPage from './pages/admin/AdminContactConfigPage'
// ...
<Route path="/admin/contact-config" element={<AdminContactConfigPage />} />
```
**`frontend/src/components/Navbar.tsx`** — acrescentar ao array `adminNavItems`:
```typescript
{ to: '/admin/contact-config', label: 'Conf. Contato' },
```
**Checkpoint — Fase 2 concluída**: Admin consegue editar e salvar a configuração de contato. Endpoint PUT retorna 401/403 para acessos não autorizados.
---
## Fase 3 — User Story 2: Página Pública de Contato Exibe Dados Dinâmicos (P1)
> **Objetivo**: A página `/contato` deixa de usar dados hardcoded e passa a consumir
> `GET /api/v1/contact-config`, preservando layout e estrutura visual existentes.
>
> **Teste independente**: Sem autenticação, acessar `/contato` e verificar que os dados
> exibidos correspondem ao banco (alterado via painel admin). A estrutura visual não muda.
### Implementação — User Story 2
- [ ] T009 [US2] Atualizar `frontend/src/pages/ContactPage.tsx` para consumir `getContactConfig()` no lugar dos dados hardcoded
**Comportamento esperado**:
- `useEffect` chama `getContactConfig()` ao montar
- Estado `config` inicializado como `null`; enquanto `loading === true` exibir skeleton ou spinner no lugar dos dados de contato (FR-021)
- Em caso de erro na requisição, exibir mensagem informativa em lugar dos dados — não renderizar valores obsoletos nem lançar erro de renderização (FR-022)
- Layout, classes CSS e demais seções da página NÃO devem ser alterados (FR-023)
**Campos a substituir** (remover literais hardcoded e usar `config.campo`):
- Endereço: `config.address_street`, `config.address_neighborhood_city`, `config.address_zip`
- Telefone: `config.phone`
- E-mail: `config.email`
- Horário de atendimento: `config.business_hours` (renderizar com `white-space: pre-line` ou equivalente para preservar quebras de linha)
**Exemplo de estrutura**:
```tsx
import { useState, useEffect } from 'react'
import { getContactConfig } from '../services/contactConfig'
import type { ContactConfig } from '../services/contactConfig'
export default function ContactPage() {
const [config, setConfig] = useState<ContactConfig | null>(null)
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState(false)
useEffect(() => {
getContactConfig()
.then(setConfig)
.catch(() => setFetchError(true))
.finally(() => setLoading(false))
}, [])
// ...restante do JSX existente — apenas substituir as strings hardcoded
// por {loading ? <Skeleton /> : fetchError ? <p>Informações indisponíveis</p> : config?.campo}
}
```
**Checkpoint — Fase 3 concluída**: `/contato` exibe dados dinâmicos da API. Todos os user stories são funcionais e testáveis de forma independente.
---
## Fase 4 — Polish & Verificações Finais
- [ ] T010 [P] Verificar que `backend/app/models/__init__.py` exporta `ContactConfig` (se o arquivo contiver imports explícitos dos modelos)
Se o arquivo importar modelos explicitamente, adicionar:
```python
from app.models.contact_config import ContactConfig # noqa: F401
```
- [ ] T011 [P] Aplicar a migration no banco de dados e verificar o registro singleton
```bash
# Dentro do container ou com .venv ativo:
flask --app run:app db upgrade
# Verificar:
# SELECT * FROM contact_config; → deve retornar 1 linha com id=1
```
---
## Dependências entre Tasks
```
T001 → T002 → T003 → T004 (blueprint público)
T005 (PUT admin)
T006 → T007 → T008 (rotas frontend)
T006 → T009 (ContactPage)
```
**Execução paralela possível**:
- T003 pode começar em paralelo com T002 (schemas não importam o modelo diretamente)
- T006, T007, T008, T009 podem ser desenvolvidos em paralelo após T001T004
---
## Escopo MVP
O **MVP mínimo** é completar as fases 1, 2 e 3 integralmente — as três user stories têm
prioridade P1 e são interdependentes para entregar valor. A fase 4 é verificação final.