- 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)
265 lines
11 KiB
Markdown
265 lines
11 KiB
Markdown
# Tasks: Feature 025 — Favoritos Locais para Visitantes
|
||
|
||
**Branch**: `025-favoritos-locais`
|
||
**Spec**: `specs/025-favoritos-locais/spec.md`
|
||
**Plan**: `specs/025-favoritos-locais/plan.md`
|
||
**Backend changes**: Nenhum
|
||
|
||
---
|
||
|
||
## Fase 1 — Foundational: Interface e Dados (Prerequisito para todos os user stories)
|
||
|
||
> Objetivo: Definir o contrato de dados `LocalFavoriteEntry` e os utilitários de
|
||
> localStorage usados por todo o restante da feature. Nenhuma mudança visível ao usuário.
|
||
|
||
- [ ] T001 Adicionar interface `LocalFavoriteEntry` e constante `LOCAL_FAV_KEY` em `frontend/src/contexts/FavoritesContext.tsx`
|
||
|
||
```typescript
|
||
// Logo antes de FavoritesContextValue
|
||
const LOCAL_FAV_KEY = 'local_favorites';
|
||
|
||
export interface LocalFavoriteEntry {
|
||
id: string;
|
||
title: string;
|
||
slug: string;
|
||
price: string;
|
||
type: 'venda' | 'aluguel';
|
||
photos: Array<{ url: string; alt_text: string }>;
|
||
city: { name: string } | null;
|
||
}
|
||
```
|
||
|
||
- [ ] T002 Adicionar `localEntries` ao tipo `FavoritesContextValue` em `frontend/src/contexts/FavoritesContext.tsx`
|
||
|
||
```typescript
|
||
interface FavoritesContextValue {
|
||
favoriteIds: Set<string>;
|
||
localEntries: LocalFavoriteEntry[]; // novo
|
||
toggle: (propertyId: string, snapshot?: LocalFavoriteEntry) => Promise<void>;
|
||
isLoading: boolean;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## Fase 2 — User Story 1: Visitante Favorita Imóvel Sem Se Cadastrar (P1)
|
||
|
||
> **Objetivo**: Ícone de coração funciona para não-autenticados, persiste via localStorage
|
||
> e sobrevive a navegação e recarga do browser.
|
||
>
|
||
> **Teste independente**: Sem login, clicar no coração de um card em `/imoveis`,
|
||
> navegar para outra página e retornar — imóvel ainda marcado como favorito.
|
||
|
||
- [ ] T003 [US1] Refatorar `useEffect([isAuthenticated])` em `frontend/src/contexts/FavoritesContext.tsx` para inicializar `favoriteIds` e `localEntries` a partir do `localStorage` quando não autenticado
|
||
|
||
**Lógica**:
|
||
- Se `!isAuthenticated`: lê `localStorage.getItem(LOCAL_FAV_KEY)`, faz parse para `LocalFavoriteEntry[]`, popula `localEntries` e deriva `favoriteIds` como `new Set(entries.map(e => e.id))`
|
||
- Se `isAuthenticated`: comportamento atual (fetch da API); `localEntries` permanece `[]`
|
||
|
||
- [ ] T004 [US1] Atualizar `toggle()` em `frontend/src/contexts/FavoritesContext.tsx` para aceitar `snapshot?: LocalFavoriteEntry` e tratar o caso não-autenticado via localStorage
|
||
|
||
**Lógica quando `!isAuthenticated`**:
|
||
```typescript
|
||
const wasIn = favoriteIds.has(propertyId);
|
||
const next = wasIn
|
||
? localEntries.filter(e => e.id !== propertyId)
|
||
: [...localEntries, snapshot ?? { id: propertyId, title: '', slug: '', price: '', type: 'venda', photos: [], city: null }];
|
||
localStorage.setItem(LOCAL_FAV_KEY, JSON.stringify(next));
|
||
setLocalEntries(next);
|
||
setFavoriteIds(new Set(next.map(e => e.id)));
|
||
return;
|
||
```
|
||
Quando autenticado: comportamento atual; `snapshot` ignorado.
|
||
|
||
- [ ] T005 [US1] Atualizar `frontend/src/components/HeartButton.tsx` — adicionar prop `snapshot?: LocalFavoriteEntry`, remover redirecionamento para `/login` e chamar `toggle(propertyId, snapshot)`
|
||
|
||
```typescript
|
||
interface HeartButtonProps {
|
||
propertyId: string;
|
||
snapshot?: LocalFavoriteEntry; // novo
|
||
className?: string;
|
||
}
|
||
|
||
// handleClick — remover: if (!isAuthenticated) { navigate('/login'); return; }
|
||
// Novo handleClick:
|
||
async function handleClick(e: React.MouseEvent) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
await toggle(propertyId, snapshot);
|
||
}
|
||
```
|
||
Remover import de `useNavigate` e `useAuth` se não forem mais necessários após esta mudança.
|
||
|
||
- [ ] T006 [P] [US1] Atualizar `frontend/src/components/PropertyRowCard.tsx` — construir snapshot e passar para `<HeartButton>`
|
||
|
||
A prop `property` já é do tipo `Property`. Compor `LocalFavoriteEntry` a partir de `property`:
|
||
```tsx
|
||
const favSnapshot: LocalFavoriteEntry = {
|
||
id: property.id,
|
||
title: property.title,
|
||
slug: property.slug,
|
||
price: property.price,
|
||
type: property.type,
|
||
photos: property.photos.slice(0, 1).map(p => ({ url: p.url, alt_text: p.alt_text })),
|
||
city: property.city ? { name: property.city.name } : null,
|
||
};
|
||
// Usar em: <HeartButton propertyId={property.id} snapshot={favSnapshot} />
|
||
```
|
||
|
||
- [ ] T007 [P] [US1] Atualizar `frontend/src/components/PropertyCard.tsx` — construir snapshot e passar para `<HeartButton>` (mesma lógica de T006)
|
||
|
||
- [ ] T008 [P] [US1] Atualizar `frontend/src/pages/PropertyDetailPage.tsx` — construir snapshot a partir de `property` e passar para `<HeartButton propertyId={property.id} snapshot={favSnapshot} />`
|
||
|
||
> O objeto `property` completo está disponível no escopo onde `HeartButton` é renderizado (linha 117).
|
||
|
||
---
|
||
|
||
## Fase 3 — User Story 2: Página Pública de Favoritos `/favoritos` (P1)
|
||
|
||
> **Objetivo**: Visitante não autenticado acessa `/favoritos` e vê os imóveis salvos
|
||
> localmente com foto, título, preço e link para o detalhe.
|
||
>
|
||
> **Teste independente**: Favoritar 3 imóveis, acessar `/favoritos` — 3 imóveis exibidos.
|
||
|
||
- [ ] T009 [US2] Criar `frontend/src/pages/PublicFavoritesPage.tsx`
|
||
|
||
**Requisitos**:
|
||
- Usa `useFavorites()` para ler `localEntries`, `favoriteIds`, `toggle`
|
||
- Estado de carregamento: verificar `isLoading`
|
||
- Estado vazio: mensagem + link para `/imoveis`
|
||
- Grid responsivo: 1 col mobile → 2 col sm → 3 col lg (igual ao `FavoritesPage.tsx` existente)
|
||
- Cada card exibe: foto (primeira do array `photos`), título, preço formatado (`Intl.NumberFormat pt-BR`), badge de tipo (`venda`/`aluguel`), cidade, link para `/imoveis/{slug}`, botão de remover (chama `toggle(entry.id)`)
|
||
- **Não** chama API — usa apenas `localEntries` do contexto
|
||
- Acessível sem autenticação (sem `ProtectedRoute`)
|
||
|
||
- [ ] T010 [US2] Registrar rota `/favoritos` em `frontend/src/App.tsx`
|
||
|
||
```tsx
|
||
import PublicFavoritesPage from './pages/PublicFavoritesPage';
|
||
|
||
// Dentro de <Routes>, após /politica-de-privacidade:
|
||
<Route path="/favoritos" element={<PublicFavoritesPage />} />
|
||
```
|
||
|
||
---
|
||
|
||
## Fase 4 — User Story 3: Banner de Incentivo ao Cadastro (P2)
|
||
|
||
> **Objetivo**: Visitante não autenticado em `/favoritos` vê banner com CTA para
|
||
> criar conta ou fazer login.
|
||
>
|
||
> **Teste independente**: Acessar `/favoritos` sem autenticação e verificar banner com
|
||
> botões "Criar conta" e "Entrar".
|
||
|
||
- [ ] T011 [US3] Adicionar `SignupBanner` inline em `frontend/src/pages/PublicFavoritesPage.tsx`
|
||
|
||
**Regra**: Banner visível apenas quando `!isAuthenticated` (obter de `useAuth()`).
|
||
|
||
**Conteúdo**:
|
||
- Ícone de coração ou nuvem
|
||
- Título: "Salve seus favoritos em qualquer dispositivo"
|
||
- Texto: "Crie uma conta gratuita para sincronizar sua lista de imóveis favoritos e acessá-la de qualquer lugar."
|
||
- Botão primário: "Criar conta" → navega para `/cadastro`
|
||
- Link secundário: "Já tenho conta — Entrar" → navega para `/login?next=/favoritos`
|
||
|
||
**Posicionamento**: Acima do grid de imóveis (ou abaixo, se a lista for vazia — nesse caso, deve ser o destaque principal da tela).
|
||
|
||
---
|
||
|
||
## Fase 5 — User Story 4: Sincronização de Favoritos Locais ao Fazer Login (P2)
|
||
|
||
> **Objetivo**: Ao fazer login, favoritos locais são mesclados automaticamente com os
|
||
> favoritos do servidor, sem duplicatas, e o localStorage é limpo.
|
||
>
|
||
> **Teste independente**: Favoritar A, B, C sem login; login em conta com A no servidor;
|
||
> `/area-do-cliente/favoritos` exibe A, B, C sem duplicatas.
|
||
|
||
- [ ] T012 [US4] Adicionar `useEffect([isAuthenticated])` de merge em `frontend/src/contexts/FavoritesContext.tsx`
|
||
|
||
**Lógica** (executada quando `isAuthenticated` muda de `false` para `true`):
|
||
```typescript
|
||
useEffect(() => {
|
||
if (!isAuthenticated) return;
|
||
const raw = localStorage.getItem(LOCAL_FAV_KEY);
|
||
if (!raw) return; // nada para sincronizar
|
||
const localEntries: LocalFavoriteEntry[] = JSON.parse(raw);
|
||
if (localEntries.length === 0) return;
|
||
|
||
(async () => {
|
||
try {
|
||
const serverFavs = await getFavorites();
|
||
const serverIds = new Set(serverFavs.filter((s: any) => s.property_id).map((s: any) => s.property_id as string));
|
||
const toAdd = localEntries.filter(e => !serverIds.has(e.id));
|
||
await Promise.allSettled(toAdd.map(e => addFavorite(e.id)));
|
||
// Limpar localStorage somente se todas as chamadas foram resolvidas (success ou already-exists)
|
||
localStorage.removeItem(LOCAL_FAV_KEY);
|
||
setLocalEntries([]);
|
||
} catch {
|
||
// Falha de rede: preservar localStorage para retentativa no próximo login
|
||
}
|
||
})();
|
||
}, [isAuthenticated]);
|
||
```
|
||
|
||
> Este `useEffect` é **separado** do `useEffect` de carregamento inicial (T003).
|
||
> Ordem de execução no mesmo ciclo de `isAuthenticated=true`:
|
||
> 1. `useEffect` de merge (T012) → adiciona ao servidor e limpa localStorage
|
||
> 2. `useEffect` de carregamento (T003) → busca favoritos atualizados do servidor
|
||
|
||
---
|
||
|
||
## Fase 6 — Polish & Verificações Finais
|
||
|
||
- [ ] T013 [P] Verificar `frontend/src/contexts/FavoritesContext.tsx` — confirmar que `localEntries` é exposto no valor do contexto e que o estado é reiniciado corretamente no logout (`isAuthenticated = false` → limpar `localEntries` **do estado**, mas **preservar** o `localStorage["local_favorites"]`)
|
||
|
||
> Ao fazer logout, os favoritos locais do localStorage são mantidos para que o visitante não os perca caso retorne sem estar logado.
|
||
|
||
- [ ] T014 [P] Verificar `frontend/src/pages/client/FavoritesPage.tsx` — confirmar que não há quebras de tipo após a alteração da assinatura de `toggle` (o `snapshot` é opcional, não deve impactar chamadas existentes)
|
||
|
||
---
|
||
|
||
## Dependências entre Fases
|
||
|
||
```
|
||
T001 → T002 → T003 → T004 ─┬─ T006 (P)
|
||
├─ T007 (P)
|
||
└─ T008 (P)
|
||
T004 → T005 (HeartButton)
|
||
|
||
T003, T004 → T009 (PublicFavoritesPage)
|
||
T009 → T010 (Rota App.tsx)
|
||
T009 → T011 (Banner)
|
||
|
||
T003, T004 → T012 (Merge login)
|
||
```
|
||
|
||
## Execução em Paralelo por Fase
|
||
|
||
| Fase | Tasks paralelas |
|
||
|------|----------------|
|
||
| Fase 1 | T001 → T002 (sequencial — mesmo arquivo) |
|
||
| Fase 2 | T003 → T004 → T005 (sequencial — mesmo arquivo); T006, T007, T008 em paralelo após T005 |
|
||
| Fase 3 | T009 → T010 (sequencial — T010 depende de T009) |
|
||
| Fase 4 | T011 (independente de T010, só depende de T009) |
|
||
| Fase 5 | T012 (independente das fases 3-4) |
|
||
| Fase 6 | T013, T014 em paralelo |
|
||
|
||
## Escopo MVP (entrega mínima P1)
|
||
|
||
Fases 1 + 2 + 3 (T001–T010): visitante favorita imóveis localmente e acessa a página `/favoritos`.
|
||
|
||
Fases 4–5 (T011–T012) entregam as histórias P2 (banner + merge no login).
|
||
|
||
---
|
||
|
||
## Contagem de Tasks
|
||
|
||
| Fase | Tasks | User Story |
|
||
|------|-------|-----------|
|
||
| Foundational | T001, T002 | — |
|
||
| US1 | T003–T008 | P1 |
|
||
| US2 | T009–T010 | P1 |
|
||
| US3 | T011 | P2 |
|
||
| US4 | T012 | P2 |
|
||
| Polish | T013–T014 | — |
|
||
| **Total** | **14 tasks** | |
|