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

37 KiB
Raw Blame History

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:
    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:
    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:
    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:
    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:
    # Antes:
    saved = (
        SavedProperty.query.filter_by(user_id=g.current_user_id)
        .order_by(SavedProperty.created_at.desc())
        .all()
    )
    
    Por:
    # 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 propertyphotos.
  • 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:
    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__):
    @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:
    @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:
    @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:
    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:
    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:
    import type {
      Boleto,
      ChangePasswordPayload,
      SavedProperty,
      UpdateProfilePayload,
      UpdateProfileResponse,
      VisitRequest,
    } from '../types/clientArea';
    
  2. 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, 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:
    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:
    const updateUser = useCallback((partial: Partial<User>) => {
      setUser(prev => (prev ? { ...prev, ...partial } : prev))
    }, [])
    
  3. Adicionar updateUser ao objeto passado para AuthContext.Provider:
    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}):

    • FavoritosHeartIcon (coração vazio)
    • CompararScaleIcon (balança/scale)
    • VisitasCalendarIcon (calendário)
    • Minha contaUserCircleIcon (usuário com círculo)
    • LogoutArrowRightOnRectangleIcon (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.

  2. Substituir navItems por:

    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:

    {/* 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:

    <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:
    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:
    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:
    import { getFavorites, removeFavorite } from '../../services/clientArea';
    
  4. 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 : []));
      }
    }
    
  5. 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:

  1. Adicionar import do serviço:
    import { cancelVisit, getVisits } from '../../services/clientArea';
    
  2. Adicionar estado para controle de cancelamento:
    const [cancelling, setCancelling] = useState<string | null>(null); // visitId em progresso
    const [cancelError, setCancelError] = useState<string | null>(null);
    
  3. 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);
      }
    }
    
  4. 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>
    )}
    
  5. Exibir cancelError se 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=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:
    <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:
    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:
    // 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:
    {/* 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:
    Select-String -Path "frontend/src/**" -Pattern "BoletosPage|ClientDashboardPage" -Recurse
    
  2. 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 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

  • T01 [P] Schemas backend: PropertyCard, UpdateProfileIn/Out, UpdatePasswordIn em backend/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.tsx e ClientDashboardPage.tsx