- 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)
11 KiB
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
LocalFavoriteEntrye os utilitários de localStorage usados por todo o restante da feature. Nenhuma mudança visível ao usuário.
-
T001 Adicionar interface
LocalFavoriteEntrye constanteLOCAL_FAV_KEYemfrontend/src/contexts/FavoritesContext.tsx// 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
localEntriesao tipoFavoritesContextValueemfrontend/src/contexts/FavoritesContext.tsxinterface 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])emfrontend/src/contexts/FavoritesContext.tsxpara inicializarfavoriteIdselocalEntriesa partir dolocalStoragequando não autenticadoLógica:
- Se
!isAuthenticated: lêlocalStorage.getItem(LOCAL_FAV_KEY), faz parse paraLocalFavoriteEntry[], populalocalEntriese derivafavoriteIdscomonew Set(entries.map(e => e.id)) - Se
isAuthenticated: comportamento atual (fetch da API);localEntriespermanece[]
- Se
-
T004 [US1] Atualizar
toggle()emfrontend/src/contexts/FavoritesContext.tsxpara aceitarsnapshot?: LocalFavoriteEntrye tratar o caso não-autenticado via localStorageLógica quando
!isAuthenticated: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;
snapshotignorado. -
T005 [US1] Atualizar
frontend/src/components/HeartButton.tsx— adicionar propsnapshot?: LocalFavoriteEntry, remover redirecionamento para/logine chamartoggle(propertyId, snapshot)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
useNavigateeuseAuthse 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
propertyjá é do tipoProperty. ComporLocalFavoriteEntrya partir deproperty: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 depropertye passar para<HeartButton propertyId={property.id} snapshot={favSnapshot} />O objeto
propertycompleto está disponível no escopo ondeHeartButtoné renderizado (linha 117).
Fase 3 — User Story 2: Página Pública de Favoritos /favoritos (P1)
Objetivo: Visitante não autenticado acessa
/favoritose 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.tsxRequisitos:
- Usa
useFavorites()para lerlocalEntries,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.tsxexistente) - 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 (chamatoggle(entry.id)) - Não chama API — usa apenas
localEntriesdo contexto - Acessível sem autenticação (sem
ProtectedRoute)
- Usa
-
T010 [US2] Registrar rota
/favoritosemfrontend/src/App.tsximport 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
/favoritosvê banner com CTA para criar conta ou fazer login.Teste independente: Acessar
/favoritossem autenticação e verificar banner com botões "Criar conta" e "Entrar".
-
T011 [US3] Adicionar
SignupBannerinline emfrontend/src/pages/PublicFavoritesPage.tsxRegra: Banner visível apenas quando
!isAuthenticated(obter deuseAuth()).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/favoritosexibe A, B, C sem duplicatas.
-
T012 [US4] Adicionar
useEffect([isAuthenticated])de merge emfrontend/src/contexts/FavoritesContext.tsxLógica (executada quando
isAuthenticatedmuda defalseparatrue):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 douseEffectde carregamento inicial (T003). Ordem de execução no mesmo ciclo deisAuthenticated=true:useEffectde merge (T012) → adiciona ao servidor e limpa localStorageuseEffectde carregamento (T003) → busca favoritos atualizados do servidor
Fase 6 — Polish & Verificações Finais
-
T013 [P] Verificar
frontend/src/contexts/FavoritesContext.tsx— confirmar quelocalEntriesé exposto no valor do contexto e que o estado é reiniciado corretamente no logout (isAuthenticated = false→ limparlocalEntriesdo estado, mas preservar olocalStorage["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 detoggle(osnapshoté 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 |