- 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)
37 KiB
Tasks — Feature 029: Revisão UX/UI da Área do Cliente
Branch: 029-ux-area-do-cliente
Gerado em: 2026-04-22
Status: Ready for implementation
Fase 1 — Backend: Schemas
T01 — Adicionar PropertyCard e schemas de profile/senha em client_area.py
Arquivo: backend/app/schemas/client_area.py
Contexto: O schema PropertyBrief só carrega id, title, slug. Os cards de favoritos precisam de price, city, neighborhood e cover_photo_url. Além disso, os endpoints de perfil e senha precisam de schemas de entrada/saída que não existem.
Passos:
- Abrir
backend/app/schemas/client_area.py. - Adicionar import
from typing import Optional(já existe) efrom decimal import Decimal(já existe). - Adicionar o schema
PropertyCardlogo apósPropertyBrief:class PropertyCard(BaseModel): id: str title: str slug: str price: Optional[Decimal] = None city: Optional[str] = None neighborhood: Optional[str] = None cover_photo_url: Optional[str] = None model_config = {"from_attributes": True} @classmethod def from_property(cls, prop) -> "PropertyCard": """Constrói PropertyCard a partir de um ORM Property.""" cover = prop.photos[0].url if prop.photos else None city = prop.city.name if prop.city else None neighborhood = prop.neighborhood.name if prop.neighborhood else None return cls( id=str(prop.id), title=prop.title, slug=prop.slug, price=prop.price, city=city, neighborhood=neighborhood, cover_photo_url=cover, ) - Alterar
SavedPropertyOutpara usarPropertyCardem vez dePropertyBrief:class SavedPropertyOut(BaseModel): id: str property_id: Optional[str] property: Optional[PropertyCard] # era PropertyBrief created_at: datetime model_config = {"from_attributes": True} - Adicionar os schemas de profile e senha ao final do arquivo:
class UpdateProfileIn(BaseModel): name: str @field_validator("name") @classmethod def name_not_empty(cls, v: str) -> str: if not v.strip(): raise ValueError("Nome não pode ser vazio") return v.strip() class UpdateProfileOut(BaseModel): id: str name: str email: str model_config = {"from_attributes": True} class UpdatePasswordIn(BaseModel): current_password: str new_password: str @field_validator("new_password") @classmethod def min_length(cls, v: str) -> str: if len(v) < 8: raise ValueError("A nova senha deve ter pelo menos 8 caracteres") return v
Critérios de conclusão:
PropertyCardexiste emclient_area.pycom os campos:id,title,slug,price,city,neighborhood,cover_photo_url.PropertyCard.from_property()extraiphotos[0].urlcomo cover ecity.name/neighborhood.namedos relacionamentos ORM.SavedPropertyOut.propertyusaPropertyCard(não maisPropertyBrief).UpdateProfileIn,UpdateProfileOuteUpdatePasswordInexistem com as validações descritas.
Fase 2 — Backend: Endpoints
T02 — Adicionar eager load de fotos na query de favoritos
Arquivo: backend/app/routes/client_area.py
Contexto: SavedProperty.property usa lazy="joined", mas Property.photos usa lazy="select". Sem selectinload, cada card de favorito dispara uma query extra para buscar as fotos (N+1). Precisa carregar as fotos junto com os favoritos.
Passos:
- Adicionar import no topo do arquivo:
from sqlalchemy.orm import selectinload from app.models.property import Property as PropertyModel - Localizar a função
get_favorites(rotaGET /favorites). - Substituir a query atual:
Por:# Antes: saved = ( SavedProperty.query.filter_by(user_id=g.current_user_id) .order_by(SavedProperty.created_at.desc()) .all() )# Depois: saved = ( SavedProperty.query .filter_by(user_id=g.current_user_id) .options(selectinload(SavedProperty.property).selectinload(PropertyModel.photos)) .order_by(SavedProperty.created_at.desc()) .all() ) - Atualizar os imports de schemas no topo do arquivo —
SavedPropertyOutagora serializaPropertyCard; não é necessária mudança de código aqui pois o schema foi alterado em T01, mas verificar quePropertyCardestá disponível viaSavedPropertyOut.
Critérios de conclusão:
- A query de favoritos usa
selectinloadparaproperty→photos. - O endpoint
GET /me/favoritesretorna o campoproperty.cover_photo_urlcom a URL da primeira foto (ounull). - O endpoint retorna
property.price,property.city,property.neighborhood. - Testado manualmente: chamada para
GET /api/me/favoritesretorna JSON com camposprice,city,neighborhood,cover_photo_urlno objetoproperty.
T03 — Implementar PATCH /me/profile
Arquivo: backend/app/routes/client_area.py
Contexto: Endpoint inexistente. Deve atualizar ClientUser.name do usuário autenticado.
Passos:
- Adicionar import:
import bcrypt from app.models.user import ClientUser from app.schemas.client_area import UpdateProfileIn, UpdateProfileOut, UpdatePasswordIn - Adicionar ao final do arquivo (antes de qualquer
if __name__):@client_bp.patch("/profile") @require_auth def update_profile(): try: data = UpdateProfileIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 user = db.session.get(ClientUser, g.current_user_id) if not user: return jsonify({"error": "Usuário não encontrado"}), 404 user.name = data.name db.session.commit() return jsonify(UpdateProfileOut.model_validate(user).model_dump(mode="json")), 200
Critérios de conclusão:
PATCH /api/me/profilecom{ "name": "Novo Nome" }retorna200com{ id, name, email }.PATCH /api/me/profilecom{ "name": "" }retorna422.PATCH /api/me/profilesem token retorna401.
T04 — Implementar PATCH /me/password
Arquivo: backend/app/routes/client_area.py
Contexto: Endpoint inexistente. Deve verificar senha atual com bcrypt antes de gravar a nova.
Passos:
- Garantir que
bcrypteClientUserestão importados (feito em T03). - Adicionar ao final do arquivo:
@client_bp.patch("/password") @require_auth def change_password(): try: data = UpdatePasswordIn.model_validate(request.get_json() or {}) except ValidationError as e: return jsonify({"error": e.errors(include_url=False)}), 422 user = db.session.get(ClientUser, g.current_user_id) if not user: return jsonify({"error": "Usuário não encontrado"}), 404 # Verifica senha atual if not bcrypt.checkpw( data.current_password.encode("utf-8"), user.password_hash.encode("utf-8"), ): return jsonify({"error": "Senha atual incorreta"}), 400 user.password_hash = bcrypt.hashpw( data.new_password.encode("utf-8"), bcrypt.gensalt() ).decode("utf-8") db.session.commit() return "", 204
Critérios de conclusão:
PATCH /api/me/passwordcom senha atual correta e nova senha ≥ 8 chars retorna204.PATCH /api/me/passwordcom senha atual incorreta retorna400com"Senha atual incorreta".PATCH /api/me/passwordcom nova senha < 8 chars retorna422.PATCH /api/me/passwordsem token retorna401.
T05 — Implementar PATCH /me/visits/<id>/cancel
Arquivo: backend/app/routes/client_area.py
Contexto: Endpoint inexistente. Deve cancelar visita com status=pending do usuário autenticado.
Passos:
- Adicionar ao final do arquivo:
@client_bp.patch("/visits/<visit_id>/cancel") @require_auth def cancel_visit(visit_id: str): visit = db.session.get(VisitRequest, visit_id) if not visit: return jsonify({"error": "Visita não encontrada"}), 404 if visit.user_id != g.current_user_id: return jsonify({"error": "Acesso negado"}), 403 if visit.status != "pending": return jsonify({"error": "Apenas visitas pendentes podem ser canceladas"}), 400 visit.status = "cancelled" db.session.commit() return jsonify(VisitRequestOut.model_validate(visit).model_dump(mode="json")), 200
Critérios de conclusão:
PATCH /api/me/visits/<id>/cancelcom visitastatus=pendingdo próprio usuário retorna200comstatus: "cancelled".- Cancelar visita com
status=confirmedretorna400. - Cancelar visita de outro usuário retorna
403. - Cancelar visita inexistente retorna
404. VisitRequestOutestá importado (já estava) e serializa o resultado corretamente.
Fase 3 — Frontend: Tipos e Serviços
T06 — Atualizar tipos em clientArea.ts
Arquivo: frontend/src/types/clientArea.ts
Contexto: SavedProperty.property só tem { id, title, slug }. Precisa incluir price, city, neighborhood, cover_photo_url. Adicionar também os tipos dos payloads de profile/senha.
Passos:
- Substituir o tipo
SavedProperty:export interface PropertyCard { id: string; title: string; slug: string; price: string | null; // Decimal serializado como string pelo backend city: string | null; neighborhood: string | null; cover_photo_url: string | null; } export interface SavedProperty { id: string; property_id: string | null; property: PropertyCard | null; created_at: string; } - Adicionar ao final do arquivo:
export interface UpdateProfilePayload { name: string; } export interface UpdateProfileResponse { id: string; name: string; email: string; } export interface ChangePasswordPayload { current_password: string; new_password: string; }
Critérios de conclusão:
SavedProperty.propertyéPropertyCard | null.PropertyCardtem todos os 7 campos listados.UpdateProfilePayload,UpdateProfileResponseeChangePasswordPayloadestão exportados.- Sem erros de TypeScript em arquivos que importam
SavedProperty.
T07 — Adicionar updateProfile, changePassword e cancelVisit em clientArea.ts
Arquivo: frontend/src/services/clientArea.ts
Contexto: O serviço atual tem getFavorites, addFavorite, removeFavorite, getVisits, getBoletos. Faltam os três novos métodos correspondentes aos endpoints criados em T03–T05.
Passos:
- Atualizar o import de tipos no topo:
import type { Boleto, ChangePasswordPayload, SavedProperty, UpdateProfilePayload, UpdateProfileResponse, VisitRequest, } from '../types/clientArea'; - Adicionar ao final do arquivo:
export async function updateProfile( data: UpdateProfilePayload, ): Promise<UpdateProfileResponse> { const response = await api.patch<UpdateProfileResponse>('/me/profile', data); return response.data; } export async function changePassword(data: ChangePasswordPayload): Promise<void> { await api.patch('/me/password', data); } export async function cancelVisit(visitId: string): Promise<VisitRequest> { const response = await api.patch<VisitRequest>(`/me/visits/${visitId}/cancel`); return response.data; }
Critérios de conclusão:
updateProfile,changePasswordecancelVisitsão exportados declientArea.ts.- Tipos corretos:
updateProfileretornaPromise<UpdateProfileResponse>,cancelVisitretornaPromise<VisitRequest>. - Sem erros TypeScript no arquivo.
Fase 4 — Frontend: AuthContext
T08 — Expor updateUser no AuthContext
Arquivo: frontend/src/contexts/AuthContext.tsx
Contexto: ProfilePage (T10) precisará atualizar user.name no contexto após salvar com sucesso, para que o sidebar reflita imediatamente o novo nome sem reload. Atualmente setUser é interno ao Provider.
Passos:
- Adicionar
updateUserà interfaceAuthContextValue:interface AuthContextValue { user: User | null token: string | null isAuthenticated: boolean isLoading: boolean login: (data: LoginCredentials) => Promise<void> register: (data: RegisterCredentials) => Promise<void> logout: () => void updateUser: (partial: Partial<User>) => void // NOVO } - Implementar
updateUserdentro doAuthProvider, após a declaração delogout:const updateUser = useCallback((partial: Partial<User>) => { setUser(prev => (prev ? { ...prev, ...partial } : prev)) }, []) - Adicionar
updateUserao objeto passado paraAuthContext.Provider:value={{ user, token, isAuthenticated: !!user, isLoading, login, register, logout, updateUser, // NOVO }}
Critérios de conclusão:
useAuth().updateUser({ name: "Novo Nome" })atualizauser.nameno contexto.- O sidebar (
ClientLayout.tsx) reflete o novo nome sem recarga de página ao chamarupdateUser. - Sem erros TypeScript no arquivo ou em consumidores de
useAuth.
Fase 5 — Frontend: Layout
T09 — Refatorar ClientLayout.tsx com ícones SVG e nova navegação
Arquivo: frontend/src/layouts/ClientLayout.tsx
Contexto: O layout atual tem 5 itens de nav com emoji Unicode inconsistentes, inclui "Painel" e "Boletos", e o botão "Sair" usa →. A nova nav tem 4 itens com SVG Heroicons.
Passos:
-
Substituir o array
navItemspor componentes SVG inline para cada ícone. Criar 4 componentes SVG pequenos no topo do arquivo (ou inline no array), usando Heroicons 2.0 outline (24×24,stroke="currentColor",strokeWidth={1.5}):- Favoritos →
HeartIcon(coração vazio) - Comparar →
ScaleIcon(balança/scale) - Visitas →
CalendarIcon(calendário) - Minha conta →
UserCircleIcon(usuário com círculo) - Logout →
ArrowRightOnRectangleIcon(seta saindo de retângulo)
Exemplo de SVG inline para
HeartIcon:function HeartIcon() { return ( <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-5 shrink-0"> <path strokeLinecap="round" strokeLinejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z" /> </svg> ); }Criar funções análogas:
ScaleIcon,CalendarIcon,UserCircleIcon,ArrowRightOnRectangleIcon. - Favoritos →
-
Substituir
navItemspor:const navItems = [ { to: '/area-do-cliente/favoritos', label: 'Favoritos', end: false, Icon: HeartIcon }, { to: '/area-do-cliente/comparar', label: 'Comparar', end: false, Icon: ScaleIcon }, { to: '/area-do-cliente/visitas', label: 'Visitas', end: false, Icon: CalendarIcon }, { to: '/area-do-cliente/conta', label: 'Minha conta', end: false, Icon: UserCircleIcon }, ];Remover
adminNavItemsou mantê-lo separado se o admin ainda precisar (não alterar funcionalidade admin). -
No render de cada
NavLink(sidebar e mobile), substituir<span className="text-base">{item.icon}</span>por<item.Icon />. -
Substituir o botão de logout:
{/* Antes */} <span>→</span>Sair {/* Depois */} <ArrowRightOnRectangleIcon />Sair -
Mobile nav: No bloco
lg:hidden, os links agora têm 4 itens. Adicionar ícones SVG também na nav mobile e centralizar:<div className="flex flex-1 justify-center gap-1"> {navItems.map(item => ( <NavLink key={item.to} to={item.to} end={item.end} className={({ isActive }) => `flex flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-xs transition ${ isActive ? 'bg-surface text-textPrimary font-medium' : 'text-textSecondary hover:text-textPrimary' }` } > <item.Icon /> {item.label} </NavLink> ))} </div>
Critérios de conclusão:
- Menu lateral tem exatamente 4 itens: Favoritos, Comparar, Visitas, Minha conta.
- "Painel" e "Boletos" não aparecem no menu.
- Todos os ícones são SVG
<svg>inline (sem emoji, sem texto Unicode). - Botão "Sair" usa
ArrowRightOnRectangleIcon. - Item ativo está visualmente destacado em ambos: sidebar (desktop) e barra (mobile).
- Mobile: 4 itens centralizados sem scroll horizontal.
- Sem erros TypeScript/JSX no arquivo.
Fase 6 — Frontend: Páginas
T10 — Criar ProfilePage.tsx
Arquivo: frontend/src/pages/client/ProfilePage.tsx (criar)
Contexto: Página inexistente. Deve exibir dois formulários independentes: edição de nome e troca de senha.
Passos:
- Criar o arquivo com a seguinte estrutura:
import { useState } from 'react'; import { useAuth } from '../../contexts/AuthContext'; import { changePassword, updateProfile } from '../../services/clientArea'; export default function ProfilePage() { const { user, updateUser } = useAuth(); // — Form de perfil — const [name, setName] = useState(user?.name ?? ''); const [nameError, setNameError] = useState(''); const [nameSaving, setNameSaving] = useState(false); const [nameSuccess, setNameSuccess] = useState(false); // — Form de senha — const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [passwordError, setPasswordError] = useState(''); const [passwordSaving, setPasswordSaving] = useState(false); const [passwordSuccess, setPasswordSuccess] = useState(false); async function handleSaveName(e: React.FormEvent) { e.preventDefault(); setNameError(''); setNameSuccess(false); if (!name.trim()) { setNameError('O nome não pode ser vazio.'); return; } setNameSaving(true); try { const updated = await updateProfile({ name: name.trim() }); updateUser({ name: updated.name }); setNameSuccess(true); } catch { setNameError('Erro ao salvar. Tente novamente.'); } finally { setNameSaving(false); } } async function handleChangePassword(e: React.FormEvent) { e.preventDefault(); setPasswordError(''); setPasswordSuccess(false); if (newPassword.length < 8) { setPasswordError('A nova senha deve ter pelo menos 8 caracteres.'); return; } if (newPassword !== confirmPassword) { setPasswordError('As senhas não coincidem.'); return; } setPasswordSaving(true); try { await changePassword({ current_password: currentPassword, new_password: newPassword }); setPasswordSuccess(true); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); } catch (err: any) { const msg = err?.response?.data?.error ?? 'Erro ao alterar senha.'; setPasswordError(msg); } finally { setPasswordSaving(false); } } return ( <div className="p-6 max-w-lg space-y-8"> <h1 className="text-xl font-semibold text-textPrimary">Minha conta</h1> {/* Formulário: dados pessoais */} <section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4"> <h2 className="text-sm font-semibold text-textPrimary">Dados pessoais</h2> <form onSubmit={handleSaveName} className="space-y-4"> <div> <label className="block text-xs text-textSecondary mb-1">Nome</label> <input type="text" value={name} onChange={e => setName(e.target.value)} className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand" /> </div> <div> <label className="block text-xs text-textSecondary mb-1">E-mail</label> <input type="email" value={user?.email ?? ''} readOnly className="w-full rounded-lg border border-borderSubtle bg-surface px-3 py-2 text-sm text-textTertiary cursor-not-allowed" /> </div> {nameError && <p className="text-xs text-red-400">{nameError}</p>} {nameSuccess && <p className="text-xs text-green-400">Nome atualizado com sucesso!</p>} <button type="submit" disabled={nameSaving} className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition" > {nameSaving ? 'Salvando…' : 'Salvar alterações'} </button> </form> </section> {/* Formulário: trocar senha */} <section className="rounded-xl border border-borderSubtle bg-panel p-6 space-y-4"> <h2 className="text-sm font-semibold text-textPrimary">Alterar senha</h2> <form onSubmit={handleChangePassword} className="space-y-4"> <div> <label className="block text-xs text-textSecondary mb-1">Senha atual</label> <input type="password" value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand" /> </div> <div> <label className="block text-xs text-textSecondary mb-1">Nova senha</label> <input type="password" value={newPassword} onChange={e => setNewPassword(e.target.value)} className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand" /> </div> <div> <label className="block text-xs text-textSecondary mb-1">Confirmar nova senha</label> <input type="password" value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} className="w-full rounded-lg border border-borderSubtle bg-canvas px-3 py-2 text-sm text-textPrimary focus:outline-none focus:border-brand" /> </div> {passwordError && <p className="text-xs text-red-400">{passwordError}</p>} {passwordSuccess && <p className="text-xs text-green-400">Senha alterada com sucesso!</p>} <button type="submit" disabled={passwordSaving} className="rounded-lg bg-brand px-4 py-2 text-sm font-medium text-white hover:bg-brandHover disabled:opacity-50 transition" > {passwordSaving ? 'Salvando…' : 'Alterar senha'} </button> </form> </section> </div> ); }
Critérios de conclusão:
- Página renderiza sem erros.
- Nome atual do usuário aparece pré-preenchido no campo "Nome".
- E-mail aparece em campo
readOnly(não editável). - Submit com nome vazio exibe erro inline sem chamar o servidor.
- Submit bem-sucedido de nome exibe "Nome atualizado com sucesso!" e o sidebar reflete o novo nome.
- Submit com
newPassword !== confirmPasswordexibe "As senhas não coincidem." sem chamar o servidor. - Submit com
newPassword.length < 8exibe erro inline. - Senha atual incorreta → backend retorna
400→ frontend exibe "Senha atual incorreta". - Após troca de senha bem-sucedida, campos de senha são limpos.
T11 — Melhorar FavoritesPage.tsx com cards enriquecidos
Arquivo: frontend/src/pages/client/FavoritesPage.tsx
Contexto: Cards atuais mostram só título e link. Com T01/T02, o backend já entrega cover_photo_url, price, city, neighborhood. Precisa exibir essas informações e melhorar o empty state.
Passos:
- Atualizar o import de tipos:
import type { SavedProperty } from '../../types/clientArea'; - Corrigir o tipo do estado:
useState<SavedProperty[]>([]). - Remover o componente
HeartButtondeste contexto — substituir pela ação "Remover dos favoritos" usandoremoveFavoritedo serviço:import { getFavorites, removeFavorite } from '../../services/clientArea'; - Implementar a função de remoção (optimistic update):
async function handleRemove(savedId: string, propertyId: string | null) { if (!propertyId) return; setFavorites(prev => prev.filter(f => f.id !== savedId)); try { await removeFavorite(propertyId); } catch { // Recarregar em caso de erro getFavorites().then(data => setFavorites(Array.isArray(data) ? data : [])); } } - Substituir os cards no retorno JSX por:
{favorites.map((item) => { const prop = item.property; const price = prop?.price ? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL', maximumFractionDigits: 0 }) .format(parseFloat(prop.price)) : null; const location = [prop?.neighborhood, prop?.city].filter(Boolean).join(', '); return ( <div key={item.id} className="rounded-xl border border-borderSubtle bg-panel overflow-hidden hover:border-borderStandard transition"> {/* Thumbnail */} <div className="relative h-40 bg-surface"> {prop?.cover_photo_url ? ( <img src={prop.cover_photo_url} alt={prop.title} className="h-full w-full object-cover" /> ) : ( <div className="flex h-full items-center justify-center text-textQuaternary text-xs"> Sem foto </div> )} </div> {/* Info */} <div className="p-4"> <p className="text-sm font-medium text-textPrimary line-clamp-2">{prop?.title ?? 'Imóvel'}</p> {price && <p className="mt-1 text-sm font-semibold text-brand">{price}</p>} {location && <p className="mt-0.5 text-xs text-textTertiary">{location}</p>} {/* Ações */} <div className="mt-3 flex items-center gap-2"> <a href={prop?.slug ? `/imoveis/${prop.slug}` : '#'} className="flex-1 rounded-lg border border-borderSubtle px-3 py-1.5 text-center text-xs text-textSecondary hover:text-textPrimary hover:border-borderStandard transition" > Ver imóvel </a> <button onClick={() => handleRemove(item.id, item.property_id)} className="rounded-lg border border-borderSubtle px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 hover:border-red-500/20 transition" > Remover </button> </div> </div> </div> ); })}
Critérios de conclusão:
- Cada card exibe: thumbnail (ou placeholder "Sem foto"), título, preço formatado em BRL, cidade/bairro.
- Botão "Remover" remove o card da lista imediatamente (optimistic update) e chama
removeFavorite. - Botão "Ver imóvel" navega para
/imoveis/<slug>. - Empty state exibe link para
/imoveis. - Sem erros TypeScript.
T12 — Melhorar VisitsPage.tsx com cancelamento de visita
Arquivo: frontend/src/pages/client/VisitsPage.tsx
Contexto: Página só exibe visitas. Precisa adicionar botão "Cancelar" para status=pending, com confirmação simples e optimistic update.
Passos:
- Adicionar import do serviço:
import { cancelVisit, getVisits } from '../../services/clientArea'; - Adicionar estado para controle de cancelamento:
const [cancelling, setCancelling] = useState<string | null>(null); // visitId em progresso const [cancelError, setCancelError] = useState<string | null>(null); - Implementar
handleCancel:async function handleCancel(visitId: string) { if (!window.confirm('Confirmar cancelamento desta visita?')) return; setCancelling(visitId); setCancelError(null); // Optimistic update setVisits(prev => prev.map(v => (v.id === visitId ? { ...v, status: 'cancelled' as const } : v)) ); try { const updated = await cancelVisit(visitId); setVisits(prev => prev.map(v => (v.id === visitId ? updated : v))); } catch (err: any) { // Reverter setCancelError('Não foi possível cancelar. Tente novamente.'); getVisits().then(setVisits).catch(() => {}); } finally { setCancelling(null); } } - Dentro do map de visitas, após o badge de status, adicionar o botão condicional:
{visit.status === 'pending' && ( <button onClick={() => handleCancel(visit.id)} disabled={cancelling === visit.id} className="mt-3 rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-400 hover:bg-red-500/10 disabled:opacity-50 transition" > {cancelling === visit.id ? 'Cancelando…' : 'Cancelar visita'} </button> )} - Exibir
cancelErrorse presente, logo após o bloco de visitas:{cancelError && ( <p className="mt-2 text-xs text-red-400">{cancelError}</p> )}
Critérios de conclusão:
- Visita com
status=pendingexibe botão "Cancelar visita". - Ao clicar,
window.confirmé exibido; se cancelado pelo usuário, nenhuma ação. - Ao confirmar, o badge de status muda imediatamente para "Cancelada" (optimistic).
- O botão "Cancelar visita" desaparece após status mudar.
- Visitas com status diferente de
pendingnão exibem o botão. - Em caso de erro de rede, mensagem de erro é exibida e lista é recarregada.
T13 — Melhorar empty state de ComparisonPage.tsx
Arquivo: frontend/src/pages/client/ComparisonPage.tsx
Contexto: O empty state atual diz "Nenhum imóvel selecionado para comparação" com link para /imoveis. Falta instrução clara de como usar a feature.
Passos:
- Localizar o bloco do empty state (quando
properties.length === 0). - Substituir o conteúdo interno do
divde empty state por:<div className="rounded-xl border border-borderSubtle bg-panel p-12 text-center max-w-sm mx-auto"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-surface"> {/* ScaleIcon SVG inline */} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6 text-textTertiary"> <path strokeLinecap="round" strokeLinejoin="round" d="M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 15.95M5.25 4.97l-2.62 15.95m0 0a48.959 48.959 0 0 0 3.32.65M5.63 20.92a48.958 48.958 0 0 0 3.32-.65m9.63.65a48.952 48.952 0 0 0 3.32-.65" /> </svg> </div> <p className="text-sm font-medium text-textPrimary mb-2">Nenhum imóvel para comparar</p> <p className="text-xs text-textTertiary mb-4"> Acesse um imóvel no catálogo e clique em{' '} <span className="font-medium text-textSecondary">"Comparar"</span>{' '} para adicioná-lo aqui. Você pode comparar até 3 imóveis lado a lado. </p> <Link to="/imoveis" className="inline-block rounded-lg bg-brand px-4 py-2 text-xs font-medium text-white hover:bg-brandHover transition" > Explorar imóveis </Link> </div>
Critérios de conclusão:
- Empty state exibe instrução explicando como adicionar imóveis à comparação.
- Instrução menciona o botão "Comparar" nos cards de imóvel.
- Link "Explorar imóveis" navega para
/imoveis. - Quando há imóveis na comparação, a tabela é exibida normalmente (sem regressão).
Fase 7 — Frontend: Roteamento
T14 — Atualizar rotas em App.tsx
Arquivo: frontend/src/App.tsx
Contexto: Precisa: (a) redirecionar /area-do-cliente para /area-do-cliente/favoritos; (b) remover a rota /boletos; (c) adicionar a rota /conta apontando para ProfilePage.
Passos:
- Adicionar import de
NavigateeProfilePage:import { Navigate } from 'react-router-dom'; // adicionar ao import existente de react-router-dom import ProfilePage from './pages/client/ProfilePage'; - Remover os imports de
BoletosPageeClientDashboardPage:// Remover estas linhas: import BoletosPage from './pages/client/BoletosPage'; import ClientDashboardPage from './pages/client/ClientDashboardPage'; - Localizar o bloco de rotas da área do cliente e substituir:
{/* Antes */} <Route index element={<ClientDashboardPage />} /> <Route path="favoritos" element={<FavoritesPage />} /> <Route path="comparar" element={<ComparisonPage />} /> <Route path="visitas" element={<VisitsPage />} /> <Route path="boletos" element={<BoletosPage />} /> {/* Depois */} <Route index element={<Navigate to="favoritos" replace />} /> <Route path="favoritos" element={<FavoritesPage />} /> <Route path="comparar" element={<ComparisonPage />} /> <Route path="visitas" element={<VisitsPage />} /> <Route path="conta" element={<ProfilePage />} />
Critérios de conclusão:
- Acessar
/area-do-clienteredireciona para/area-do-cliente/favoritos(Replace — sem entrada no histórico). - A rota
/area-do-cliente/boletosnão existe mais (retorna 404 do React Router). - A rota
/area-do-cliente/contarenderizaProfilePage. - Sem erros TypeScript/lint no arquivo.
Fase 8 — Remoção de Arquivos Obsoletos
T15 — Deletar BoletosPage.tsx e ClientDashboardPage.tsx
Arquivos a deletar:
frontend/src/pages/client/BoletosPage.tsxfrontend/src/pages/client/ClientDashboardPage.tsx
Contexto: Após T14, nenhum import desses componentes existe mais no projeto. Removê-los evita confusão futura.
Passos:
- Verificar que nenhum arquivo do projeto importa
BoletosPageouClientDashboardPage:Select-String -Path "frontend/src/**" -Pattern "BoletosPage|ClientDashboardPage" -Recurse - Se o resultado for vazio (apenas os próprios arquivos), deletar:
Remove-Item "frontend/src/pages/client/BoletosPage.tsx" Remove-Item "frontend/src/pages/client/ClientDashboardPage.tsx"
Critérios de conclusão:
- Os dois arquivos não existem mais no projeto.
- Nenhum arquivo do projeto os importa.
- Build do frontend (ou
tsc --noEmit) passa sem erros.
Grafo de Dependências
T01 (Schemas backend)
└─ T02 (Eager load GET /favorites) → T11 (FavoritesPage)
└─ T03 (PATCH /profile) → T07 (cancelVisit/updateProfile) → T10 (ProfilePage)
└─ T04 (PATCH /password) ↗
└─ T05 (PATCH /visits/cancel) → T07 (cancelVisit) → T12 (VisitsPage)
T06 (Tipos TS) → T07 (Serviços) → T10, T11, T12
T08 (AuthContext.updateUser) → T10 (ProfilePage usa updateUser)
T09 (ClientLayout) — independente, pode ser feito em paralelo com T01–T08
T13 (ComparisonPage empty state) — independente
T14 (App.tsx rotas) → depende de T10 (ProfilePage deve existir)
T15 (Deletar arquivos) → depende de T14
Execução em Paralelo
| Grupo paralelo | Tasks |
|---|---|
| 1 (backend) | T01 → (T02, T03, T04, T05) em paralelo após T01 |
| 2 (frontend infra) | T06, T08, T09, T13 podem ser feitos em paralelo entre si |
| 3 (serviços) | T07 após T06 |
| 4 (páginas) | T10 após T03+T04+T08+T07; T11 após T02+T06+T07; T12 após T05+T06+T07 |
| 5 (finalização) | T14 após T10; T15 após T14 |
Checklist Resumida
- T01 [P] Schemas backend:
PropertyCard,UpdateProfileIn/Out,UpdatePasswordInembackend/app/schemas/client_area.py - T12 [US3] Cancelamento em
frontend/src/pages/client/VisitsPage.tsx - T13 [P] [US6] Empty state em
frontend/src/pages/client/ComparisonPage.tsx - T14 Rotas em
frontend/src/App.tsx - T15 Deletar
BoletosPage.tsxeClientDashboardPage.tsx