- 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)
14 KiB
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_configcom INSERT inicial embackend/migrations/versions/h2i3j4k5l6m7_add_contact_config.pyComando para gerar a migration (executar de dentro do container ou com
.venv):flask --app run:app db revision --autogenerate -m "add_contact_config"A migration deve:
- Criar a tabela com os campos abaixo:
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()), )- Inserir o registro singleton com os valores atualmente hardcoded:
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
ContactConfigembackend/app/models/contact_config.pyseguindo o padrão deHomepageConfigfrom 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.pyseguindo o padrão deHomepageConfigOut/HomepageConfigInfrom __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-configembackend/app/routes/contact_config.pye registrar o blueprint embackend/app/__init__.pybackend/app/routes/contact_config.py: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 dehomepage_bp:from app.routes.contact_config import contact_config_bpE registrar junto aos demais blueprints:
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". VerificarGET /api/v1/contact-configretorna o novo valor. Verificar quePUT /api/v1/admin/contact-configsem token retorna HTTP 401.
Implementação — User Stories 1 + 3
-
T005 [US1] Adicionar rota
PUT /api/v1/admin/contact-configembackend/app/routes/admin.pycom@require_adminAdicionar imports no topo de
backend/app/routes/admin.py:from app.models.contact_config import ContactConfig from app.schemas.contact_config import ContactConfigIn, ContactConfigOutAdicionar a rota (em qualquer ponto lógico do arquivo, ex.: próximo a outras rotas de configuração):
@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_admingarante HTTP 401 para não-autenticados e HTTP 403 para não-admins (US3). -
T006 [P] [US1] Criar
frontend/src/services/contactConfig.tscomgetContactConfig()eupdateContactConfig()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.tsxseguindo o padrão visual das demais páginas admin (ex.:AdminAgentsPage.tsx)Comportamento esperado:
useEffectfazGET /api/v1/contact-configao montar e pré-preenche o form- Estado local
formcom os 6 campos editáveis handleSubmitchamaupdateContactConfig(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:
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-configemfrontend/src/App.tsxe adicionar item{ to: '/admin/contact-config', label: 'Conf. Contato' }emadminNavItemsemfrontend/src/components/Navbar.tsxfrontend/src/App.tsx— localizar o trecho de rotas admin e adicionar:import AdminContactConfigPage from './pages/admin/AdminContactConfigPage' // ... <Route path="/admin/contact-config" element={<AdminContactConfigPage />} />frontend/src/components/Navbar.tsx— acrescentar ao arrayadminNavItems:{ 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
/contatodeixa de usar dados hardcoded e passa a consumirGET /api/v1/contact-config, preservando layout e estrutura visual existentes.Teste independente: Sem autenticação, acessar
/contatoe 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.tsxpara consumirgetContactConfig()no lugar dos dados hardcodedComportamento esperado:
useEffectchamagetContactConfig()ao montar- Estado
configinicializado comonull; enquantoloading === trueexibir 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 comwhite-space: pre-lineou equivalente para preservar quebras de linha)
Exemplo de estrutura:
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__.pyexportaContactConfig(se o arquivo contiver imports explícitos dos modelos)Se o arquivo importar modelos explicitamente, adicionar:
from app.models.contact_config import ContactConfig # noqa: F401 -
T011 [P] Aplicar a migration no banco de dados e verificar o registro singleton
# 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 T001–T004
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.