sass-imobiliaria/specs/027-config-pagina-contato/tasks.md
MatheusAlves96 cf5603243c
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: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- 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)
2026-04-22 22:35:17 -03:00

404 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.