feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
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(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)
This commit is contained in:
MatheusAlves96 2026-04-22 22:35:17 -03:00
parent 6ef5a7a17e
commit cf5603243c
106 changed files with 11927 additions and 1367 deletions

View file

@ -0,0 +1,994 @@
# 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`