feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
393
.specify/features/018-homepage-scroll-hero/plan.md
Normal file
393
.specify/features/018-homepage-scroll-hero/plan.md
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
# 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")`
|
||||
|
||||
```python
|
||||
# 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`:
|
||||
|
||||
```python
|
||||
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`:
|
||||
```python
|
||||
@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:
|
||||
|
||||
```typescript
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
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>`:
|
||||
|
||||
```typescript
|
||||
// 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:
|
||||
|
||||
```tsx
|
||||
{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
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
```tsx
|
||||
{/* 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:
|
||||
|
||||
```tsx
|
||||
{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:
|
||||
|
||||
```tsx
|
||||
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:
|
||||
|
||||
```tsx
|
||||
style={{
|
||||
animation: `fadeDown 1.4s ease-in-out ${i * 0.2}s infinite`,
|
||||
}}
|
||||
```
|
||||
|
||||
Adicionar no bloco `<style>` existente:
|
||||
|
||||
```css
|
||||
@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>`:
|
||||
|
||||
```tsx
|
||||
// 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)
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
|
||||
```typescript
|
||||
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:
|
||||
> ```tsx
|
||||
> <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
|
||||
Loading…
Add table
Add a link
Reference in a new issue