sass-imobiliaria/specs/029-ux-area-do-cliente/tasks.md
MatheusAlves96 cf5603243c
Some checks failed
CI/CD → Deploy via SSH / Build & Push Docker Images (push) Successful in 1m0s
CI/CD → Deploy via SSH / Deploy via SSH (push) Successful in 4m35s
CI/CD → Deploy via SSH / Validate HTTPS & Endpoints (push) Failing after 46s
feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- 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)
2026-04-22 22:35:17 -03:00

994 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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**:
1. Abrir `backend/app/schemas/client_area.py`.
2. Adicionar import `from typing import Optional` (já existe) e `from decimal import Decimal` (já existe).
3. Adicionar o schema `PropertyCard` logo após `PropertyBrief`:
```python
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,
)
```
4. Alterar `SavedPropertyOut` para usar `PropertyCard` em vez de `PropertyBrief`:
```python
class SavedPropertyOut(BaseModel):
id: str
property_id: Optional[str]
property: Optional[PropertyCard] # era PropertyBrief
created_at: datetime
model_config = {"from_attributes": True}
```
5. Adicionar os schemas de profile e senha ao final do arquivo:
```python
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**:
- [ ] `PropertyCard` existe em `client_area.py` com os campos: `id`, `title`, `slug`, `price`, `city`, `neighborhood`, `cover_photo_url`.
- [ ] `PropertyCard.from_property()` extrai `photos[0].url` como cover e `city.name` / `neighborhood.name` dos relacionamentos ORM.
- [ ] `SavedPropertyOut.property` usa `PropertyCard` (não mais `PropertyBrief`).
- [ ] `UpdateProfileIn`, `UpdateProfileOut` e `UpdatePasswordIn` existem 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**:
1. Adicionar import no topo do arquivo:
```python
from sqlalchemy.orm import selectinload
from app.models.property import Property as PropertyModel
```
2. Localizar a função `get_favorites` (rota `GET /favorites`).
3. Substituir a query atual:
```python
# Antes:
saved = (
SavedProperty.query.filter_by(user_id=g.current_user_id)
.order_by(SavedProperty.created_at.desc())
.all()
)
```
Por:
```python
# 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()
)
```
4. Atualizar os imports de schemas no topo do arquivo — `SavedPropertyOut` agora serializa `PropertyCard`; não é necessária mudança de código aqui pois o schema foi alterado em T01, mas verificar que `PropertyCard` está disponível via `SavedPropertyOut`.
**Critérios de conclusão**:
- [ ] A query de favoritos usa `selectinload` para `property` → `photos`.
- [ ] O endpoint `GET /me/favorites` retorna o campo `property.cover_photo_url` com a URL da primeira foto (ou `null`).
- [ ] O endpoint retorna `property.price`, `property.city`, `property.neighborhood`.
- [ ] Testado manualmente: chamada para `GET /api/me/favorites` retorna JSON com campos `price`, `city`, `neighborhood`, `cover_photo_url` no objeto `property`.
---
### T03 — Implementar `PATCH /me/profile`
**Arquivo**: `backend/app/routes/client_area.py`
**Contexto**: Endpoint inexistente. Deve atualizar `ClientUser.name` do usuário autenticado.
**Passos**:
1. Adicionar import:
```python
import bcrypt
from app.models.user import ClientUser
from app.schemas.client_area import UpdateProfileIn, UpdateProfileOut, UpdatePasswordIn
```
2. Adicionar ao final do arquivo (antes de qualquer `if __name__`):
```python
@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/profile` com `{ "name": "Novo Nome" }` retorna `200` com `{ id, name, email }`.
- [ ] `PATCH /api/me/profile` com `{ "name": "" }` retorna `422`.
- [ ] `PATCH /api/me/profile` sem token retorna `401`.
---
### 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**:
1. Garantir que `bcrypt` e `ClientUser` estão importados (feito em T03).
2. Adicionar ao final do arquivo:
```python
@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/password` com senha atual correta e nova senha ≥ 8 chars retorna `204`.
- [ ] `PATCH /api/me/password` com senha atual incorreta retorna `400` com `"Senha atual incorreta"`.
- [ ] `PATCH /api/me/password` com nova senha < 8 chars retorna `422`.
- [ ] `PATCH /api/me/password` sem token retorna `401`.
---
### 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**:
1. Adicionar ao final do arquivo:
```python
@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>/cancel` com visita `status=pending` do próprio usuário retorna `200` com `status: "cancelled"`.
- [ ] Cancelar visita com `status=confirmed` retorna `400`.
- [ ] Cancelar visita de outro usuário retorna `403`.
- [ ] Cancelar visita inexistente retorna `404`.
- [ ] `VisitRequestOut` está 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**:
1. Substituir o tipo `SavedProperty`:
```typescript
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;
}
```
2. Adicionar ao final do arquivo:
```typescript
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`.
- [ ] `PropertyCard` tem todos os 7 campos listados.
- [ ] `UpdateProfilePayload`, `UpdateProfileResponse` e `ChangePasswordPayload` estã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 T03T05.
**Passos**:
1. Atualizar o import de tipos no topo:
```typescript
import type {
Boleto,
ChangePasswordPayload,
SavedProperty,
UpdateProfilePayload,
UpdateProfileResponse,
VisitRequest,
} from '../types/clientArea';
```
2. Adicionar ao final do arquivo:
```typescript
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`, `changePassword` e `cancelVisit` são exportados de `clientArea.ts`.
- [ ] Tipos corretos: `updateProfile` retorna `Promise<UpdateProfileResponse>`, `cancelVisit` retorna `Promise<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**:
1. Adicionar `updateUser` à interface `AuthContextValue`:
```typescript
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
}
```
2. Implementar `updateUser` dentro do `AuthProvider`, após a declaração de `logout`:
```typescript
const updateUser = useCallback((partial: Partial<User>) => {
setUser(prev => (prev ? { ...prev, ...partial } : prev))
}, [])
```
3. Adicionar `updateUser` ao objeto passado para `AuthContext.Provider`:
```typescript
value={{
user,
token,
isAuthenticated: !!user,
isLoading,
login,
register,
logout,
updateUser, // NOVO
}}
```
**Critérios de conclusão**:
- [ ] `useAuth().updateUser({ name: "Novo Nome" })` atualiza `user.name` no contexto.
- [ ] O sidebar (`ClientLayout.tsx`) reflete o novo nome sem recarga de página ao chamar `updateUser`.
- [ ] 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**:
1. Substituir o array `navItems` por 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`:
```tsx
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`.
2. Substituir `navItems` por:
```tsx
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 `adminNavItems` ou mantê-lo separado se o admin ainda precisar (não alterar funcionalidade admin).
3. No render de cada `NavLink` (sidebar e mobile), substituir `<span className="text-base">{item.icon}</span>` por `<item.Icon />`.
4. Substituir o botão de logout:
```tsx
{/* Antes */}
<span>→</span>Sair
{/* Depois */}
<ArrowRightOnRectangleIcon />Sair
```
5. **Mobile nav**: No bloco `lg:hidden`, os links agora têm 4 itens. Adicionar ícones SVG também na nav mobile e centralizar:
```tsx
<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**:
1. Criar o arquivo com a seguinte estrutura:
```tsx
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 !== confirmPassword` exibe "As senhas não coincidem." sem chamar o servidor.
- [ ] Submit com `newPassword.length < 8` exibe 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**:
1. Atualizar o import de tipos:
```typescript
import type { SavedProperty } from '../../types/clientArea';
```
2. Corrigir o tipo do estado: `useState<SavedProperty[]>([])`.
3. Remover o componente `HeartButton` deste contexto — substituir pela ação "Remover dos favoritos" usando `removeFavorite` do serviço:
```typescript
import { getFavorites, removeFavorite } from '../../services/clientArea';
```
4. Implementar a função de remoção (optimistic update):
```typescript
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 : []));
}
}
```
5. Substituir os cards no retorno JSX por:
```tsx
{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**:
1. Adicionar import do serviço:
```typescript
import { cancelVisit, getVisits } from '../../services/clientArea';
```
2. Adicionar estado para controle de cancelamento:
```typescript
const [cancelling, setCancelling] = useState<string | null>(null); // visitId em progresso
const [cancelError, setCancelError] = useState<string | null>(null);
```
3. Implementar `handleCancel`:
```typescript
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);
}
}
```
4. Dentro do map de visitas, após o badge de status, adicionar o botão condicional:
```tsx
{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>
)}
```
5. Exibir `cancelError` se presente, logo após o bloco de visitas:
```tsx
{cancelError && (
<p className="mt-2 text-xs text-red-400">{cancelError}</p>
)}
```
**Critérios de conclusão**:
- [ ] Visita com `status=pending` exibe 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 `pending` nã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**:
1. Localizar o bloco do empty state (quando `properties.length === 0`).
2. Substituir o conteúdo interno do `div` de empty state por:
```tsx
<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**:
1. Adicionar import de `Navigate` e `ProfilePage`:
```typescript
import { Navigate } from 'react-router-dom'; // adicionar ao import existente de react-router-dom
import ProfilePage from './pages/client/ProfilePage';
```
2. Remover os imports de `BoletosPage` e `ClientDashboardPage`:
```typescript
// Remover estas linhas:
import BoletosPage from './pages/client/BoletosPage';
import ClientDashboardPage from './pages/client/ClientDashboardPage';
```
3. Localizar o bloco de rotas da área do cliente e substituir:
```tsx
{/* 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-cliente` redireciona para `/area-do-cliente/favoritos` (Replace — sem entrada no histórico).
- [ ] A rota `/area-do-cliente/boletos` não existe mais (retorna 404 do React Router).
- [ ] A rota `/area-do-cliente/conta` renderiza `ProfilePage`.
- [ ] 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.tsx`
- `frontend/src/pages/client/ClientDashboardPage.tsx`
**Contexto**: Após T14, nenhum import desses componentes existe mais no projeto. Removê-los evita confusão futura.
**Passos**:
1. Verificar que nenhum arquivo do projeto importa `BoletosPage` ou `ClientDashboardPage`:
```powershell
Select-String -Path "frontend/src/**" -Pattern "BoletosPage|ClientDashboardPage" -Recurse
```
2. Se o resultado for vazio (apenas os próprios arquivos), deletar:
```powershell
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 T01T08
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
- [X] T01 [P] Schemas backend: `PropertyCard`, `UpdateProfileIn/Out`, `UpdatePasswordIn` em `backend/app/schemas/client_area.py`
- [X] T12 [US3] Cancelamento em `frontend/src/pages/client/VisitsPage.tsx`
- [X] T13 [P] [US6] Empty state em `frontend/src/pages/client/ComparisonPage.tsx`
- [X] T14 Rotas em `frontend/src/App.tsx`
- [X] T15 Deletar `BoletosPage.tsx` e `ClientDashboardPage.tsx`