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

14 KiB
Raw Blame History

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):

    flask --app run:app db revision --autogenerate -m "add_contact_config"
    

    A migration deve:

    1. 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()),
    )
    
    1. 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 ContactConfig em backend/app/models/contact_config.py seguindo o padrão de HomepageConfig

    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

    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:

    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:

    from app.routes.contact_config import contact_config_bp
    

    E 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". 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:

    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):

    @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()

    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:

    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:

    import AdminContactConfigPage from './pages/admin/AdminContactConfigPage'
    // ...
    <Route path="/admin/contact-config" element={<AdminContactConfigPage />} />
    

    frontend/src/components/Navbar.tsx — acrescentar ao array adminNavItems:

    { 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:

    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:

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