sass-imobiliaria/.specify/features/018-homepage-scroll-hero/plan.md

13 KiB

Plano de Implementação — Feature 018: Homepage Imersiva com Scroll (Hero + Destaques)

Objetivo

Transformar a homepage em uma experiência imersiva de scroll-sticky: o hero ocupa 100vh com imagem de fundo configurável, overlay escuro e texto centralizado; ao rolar, os cards de imóveis em destaque sobem sobre o hero com animação de entrada. O texto do hero (headline, subheadline, CTA) é movido para dentro do HomeScrollScene; a HeroSection separada é removida do fluxo principal.

Escopo

  • Backend: nova coluna hero_image_url na tabela homepage_config, migration Alembic, exposição no schema/rota
  • Frontend — tipos: adicionar hero_image_url a HomepageConfig
  • Frontend — HeroSection.tsx: adicionar prop backgroundImage (mantida para uso isolado/admin futuro)
  • Frontend — HomeScrollScene.tsx: aceitar todas as props do hero e renderizar headline/subheadline/CTA dentro do container sticky
  • Frontend — HomePage.tsx: remover <HeroSection> separado; passar todo o config para HomeScrollScene
  • Fallback de imagem padrão quando hero_image_url for nulo/vazio/inválido
  • Suporte a prefers-reduced-motion

Tarefas

1. Backend — Migration Alembic

Arquivo: backend/migrations/versions/<id>_add_hero_image_url_to_homepage_config.py

  • Criar migration manual seguindo o padrão existente (ex: c8d9e0f1a2b3):
    • revision: novo ID hexadecimal (ex: d1e2f3a4b5c6)
    • down_revision: c8d9e0f1a2b3 (última migration conhecida)
    • upgrade(): op.add_column("homepage_config", sa.Column("hero_image_url", sa.String(512), nullable=True))
    • downgrade(): op.drop_column("homepage_config", "hero_image_url")
# Exemplo de estrutura mínima
"""add hero_image_url to homepage_config

Revision ID: d1e2f3a4b5c6
Revises: c8d9e0f1a2b3
Create Date: 2026-04-17 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa

revision = "d1e2f3a4b5c6"
down_revision = "c8d9e0f1a2b3"
branch_labels = None
depends_on = None

def upgrade():
    op.add_column(
        "homepage_config",
        sa.Column("hero_image_url", sa.String(length=512), nullable=True),
    )

def downgrade():
    op.drop_column("homepage_config", "hero_image_url")

2. Backend — Modelo HomepageConfig

Arquivo: backend/app/models/homepage.py

Adicionar campo após featured_properties_limit:

hero_image_url = db.Column(db.String(512), nullable=True)

3. Backend — Schemas Pydantic

Arquivo: backend/app/schemas/homepage.py

  • Em HomepageConfigOut: adicionar hero_image_url: str | None = None
  • Em HomepageConfigIn: adicionar hero_image_url: str | None = None
    • Adicionar validator que trata string vazia como None:
      @field_validator("hero_image_url", mode="before")
      @classmethod
      def empty_str_to_none(cls, v: str | None) -> str | None:
          if isinstance(v, str) and not v.strip():
              return None
          return v
      

A rota GET /api/v1/homepage-config já serializa via HomepageConfigOut.model_dump() — nenhuma alteração necessária na rota.


4. Frontend — Tipo HomepageConfig

Arquivo: frontend/src/types/homepage.ts

Adicionar campo:

export interface HomepageConfig {
    hero_headline: string
    hero_subheadline: string | null
    hero_cta_label: string
    hero_cta_url: string
    featured_properties_limit: number
    hero_image_url?: string | null   // ← novo
}

Atualizar FALLBACK_CONFIG em HomePage.tsx:

const FALLBACK_CONFIG: HomepageConfig = {
    hero_headline: 'Encontre o imóvel dos seus sonhos',
    hero_subheadline: 'Os melhores imóveis para comprar ou alugar na sua região',
    hero_cta_label: 'Ver Imóveis',
    hero_cta_url: '/imoveis',
    featured_properties_limit: 6,
    hero_image_url: null,            // ← novo
}

5. Frontend — HeroSection.tsx (atualização de interface)

Arquivo: frontend/src/components/HeroSection.tsx

Adicionar prop backgroundImage?: string | null à interface. No estado loaded, usar backgroundImage como background-image inline (quando presente) em vez do gradiente CSS, com overlay escuro por cima.

interface HeroSectionProps {
    headline: string
    subheadline: string | null
    ctaLabel: string
    ctaUrl: string
    isLoading?: boolean
    backgroundImage?: string | null  // ← novo
}

No JSX do estado loaded, substituir o style do <section>:

// Se backgroundImage presente:
style={backgroundImage
    ? { backgroundImage: `url(${backgroundImage})`, backgroundSize: 'cover', backgroundPosition: 'center' }
    : { background: 'radial-gradient(ellipse 80% 50% at 50% -20%, rgba(94,106,210,0.08) 0%, transparent 60%), #08090a' }
}

Adicionar overlay escuro semitransparente como div inside a section (antes do conteúdo), quando backgroundImage estiver presente:

{backgroundImage && (
    <div
        className="absolute inset-0 pointer-events-none"
        style={{ background: 'rgba(0,0,0,0.52)' }}
        aria-hidden="true"
    />
)}

Garantir que o conteúdo fique com position: relative; z-index: 1 para ficar acima do overlay.


6. Frontend — HomeScrollScene.tsx (principal)

Arquivo: frontend/src/components/HomeScrollScene.tsx

6a. Atualizar interface de props

interface HomeScrollSceneProps {
    headline: string
    subheadline?: string | null
    ctaLabel?: string
    ctaUrl?: string
    backgroundImage?: string | null
    isLoading?: boolean
}

6b. Renderizar hero text dentro do container sticky

O bloco <div className="sticky top-0 h-screen ..."> já existe e contém a imagem/gradiente e <ScrollHint>. Adicionar dentro desse bloco, após a imagem e o overlay de gradiente e antes do <ScrollHint>, o conteúdo do hero:

{/* Hero text — centralizado sobre a imagem */}
<div className="absolute inset-0 flex flex-col items-center justify-center px-6 text-center z-10 pt-14">
    {isLoading ? (
        <div className="w-full max-w-[800px] animate-pulse">
            <div className="h-16 md:h-20 lg:h-24 bg-white/[0.06] rounded-lg w-3/4 mx-auto mb-6" />
            <div className="h-6 bg-white/[0.06] rounded w-1/2 mx-auto mb-3" />
            <div className="h-6 bg-white/[0.06] rounded w-2/5 mx-auto mb-10" />
            <div className="h-11 bg-white/[0.06] rounded w-36 mx-auto" />
        </div>
    ) : (
        <div className="w-full max-w-[800px]">
            <h1
                className="text-[40px] md:text-[48px] lg:text-[72px] font-medium text-white leading-tight tracking-display-xl mb-6"
                style={{ fontFeatureSettings: '"cv01", "ss03"', textShadow: '0 2px 16px rgba(0,0,0,0.5)' }}
            >
                {headline}
            </h1>
            {subheadline && (
                <p className="text-lg md:text-xl text-white/70 font-light leading-relaxed mb-10 max-w-[560px] mx-auto"
                   style={{ textShadow: '0 1px 8px rgba(0,0,0,0.4)' }}>
                    {subheadline}
                </p>
            )}
            <a
                href={ctaUrl ?? '/imoveis'}
                className="inline-flex items-center justify-center px-6 py-3 bg-brand hover:bg-accentHover text-white font-semibold text-sm rounded transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
                aria-label={ctaLabel}
            >
                {ctaLabel ?? 'Ver Imóveis'}
            </a>
        </div>
    )}
</div>

6c. Overlay escuro sobre a imagem de fundo

Após o bloco {imageUrl ? <img> : <div gradiente>} e antes do overlay de gradiente existente (que suaviza edges), adicionar overlay semitransparente quando backgroundImage estiver presente:

{backgroundImage && (
    <div
        className="absolute inset-0 pointer-events-none"
        style={{ background: 'rgba(0,0,0,0.50)' }}
        aria-hidden="true"
    />
)}

6d. Remover prop imageUrl legada, renomear para backgroundImage

A prop atual é imageUrl?: string. Renomear internamente para backgroundImage para consistência com o resto do plano.

6e. prefers-reduced-motion

No RiseCard, substituir as classes transition-all duration-700 e translate-y-12 com suporte a reduced-motion:

className={`
    transition-all duration-700 ease-out
    motion-reduce:transition-none motion-reduce:translate-y-0
    ${visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-12'}
`}

No ScrollHint, nos chevrons com animation inline, adicionar CSS @media (prefers-reduced-motion: reduce) desabilitando a animação:

style={{
    animation: `fadeDown 1.4s ease-in-out ${i * 0.2}s infinite`,
}}

Adicionar no bloco <style> existente:

@media (prefers-reduced-motion: reduce) {
    .scroll-chevron { animation: none !important; }
}

E adicionar className="scroll-chevron" nos chevrons do ScrollHint.


7. Frontend — HomePage.tsx (simplificação)

Arquivo: frontend/src/pages/HomePage.tsx

  • Remover o import e o uso de <HeroSection> do retorno JSX
  • Passar todas as props do hero para <HomeScrollScene>:
// Antes:
<HeroSection
    headline={config.hero_headline}
    subheadline={config.hero_subheadline}
    ctaLabel={config.hero_cta_label}
    ctaUrl={config.hero_cta_url}
    isLoading={isLoading}
/>
<HomeScrollScene />

// Depois:
<HomeScrollScene
    headline={config.hero_headline}
    subheadline={config.hero_subheadline}
    ctaLabel={config.hero_cta_label}
    ctaUrl={config.hero_cta_url}
    backgroundImage={config.hero_image_url ?? null}
    isLoading={isLoading}
/>
  • Remover import de HeroSection
  • O FALLBACK_CONFIG já terá hero_image_url: null após a tarefa 4

8. Backend — Admin: exposição do campo hero_image_url no painel (opcional/P3)

Fora do escopo crítico desta feature — a spec marca US-5 como P3. O painel admin não possui AdminHomepagePage ainda. O campo hero_image_url pode ser configurado diretamente via endpoint PATCH no banco até que a UI admin seja criada em feature futura.

Se desejado nesta feature: adicionar endpoint PATCH /api/v1/admin/homepage-config em backend/app/routes/admin.py que aceita HomepageConfigIn e atualiza o registro único da tabela.


Ordem de Execução Recomendada

1. Migration Alembic   → backend/migrations/versions/
2. Modelo HomepageConfig  → backend/app/models/homepage.py
3. Schemas Pydantic    → backend/app/schemas/homepage.py
4. Tipo TS             → frontend/src/types/homepage.ts
5. HomeScrollScene     → adicionar hero props e renderização
6. HeroSection         → adicionar backgroundImage prop
7. HomePage            → remover HeroSection, passar props
8. (Opcional) Admin PATCH endpoint

Contratos de Dados

GET /api/v1/homepage-config — response (após migration)

{
  "hero_headline": "Encontre o imóvel dos seus sonhos",
  "hero_subheadline": "Os melhores imóveis para comprar ou alugar na sua região",
  "hero_cta_label": "Ver Imóveis",
  "hero_cta_url": "/imoveis",
  "featured_properties_limit": 6,
  "hero_image_url": "https://cdn.example.com/hero.jpg"
}

HomeScrollScene props

interface HomeScrollSceneProps {
    headline: string
    subheadline?: string | null
    ctaLabel?: string
    ctaUrl?: string
    backgroundImage?: string | null
    isLoading?: boolean
}

Comportamento de Fallback

Situação Comportamento
hero_image_url é null ou não configurado Gradiente CSS existente é exibido como fundo
hero_image_url é string vazia "" Tratada como null pelo validator Pydantic
Imagem de URL falha ao carregar (404/timeout) Browser exibe fundo vazio; o gradiente CSS deve ser mantido como background do container (aplicar o gradiente como background do <div> wrapper, a <img> fica por cima com object-fit: cover)
prefers-reduced-motion ativado Animações translateY e chevron cascade são desabilitadas

Implementação do fallback de imagem quebrada: usar onError na <img> para esconder/remover a imagem, deixando o gradiente visível:

<img
    src={backgroundImage}
    onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
    ...
/>

O gradiente CSS deve estar sempre presente no background do container — a imagem fica posicionada absolute inset-0 por cima.


Critérios de Aceite (ligados às User Stories da spec)

  • US-1: Hero ocupa 100vh, overlay escuro garante legibilidade do texto branco, imagem de fundo ou gradiente como fallback
  • US-1: Texto (headline, subheadline, CTA) centralizado sobre o fundo
  • US-2: Scroll sticky funciona — imagem permanece enquanto cards sobem
  • US-2: Cada card tem animação opacity 0→1 + translateY 48px→0 com stagger de 60ms (max 240ms)
  • US-3: ScrollHint visível sobre o hero com 3 chevrons em cascata
  • US-4: Redirecionamento para /imoveis após 800ms quando sentinel 100% visível
  • US-4: Overlay de transição (blur + spinner) antes de navegar
  • US-5: hero_image_url salvo no DB e retornado na API após migration
  • Edge: imagem com URL inválida → fallback para gradiente
  • Edge: prefers-reduced-motion → sem translateY nem chevron cascade
  • Edge: FALLBACK_CONFIG funciona sem hero_image_url configurado