Skip to content

Hooks & Contextos

Custom hooks React e contextos globais do frontend.


Hooks (frontend/hooks/)


useStableToken

Arquivo: hooks/useStableToken.ts | Diretiva: "use client"

Hook canônico para qualquer página que faz fetch autenticado. Resolve o problema de re-renders causados pela rotação de JWT do Clerk.

Problema raiz: O Clerk rotaciona o JWT a cada ~60s emitindo uma nova referência de função getToken. Se getToken estiver nos deps de um useEffect, o efeito dispara a cada rotação → setLoading(true) → dados somem da tela.

Solução: Guarda getToken em uma useRef (atualizada a cada render) e expõe stableGetToken via useCallback com deps vazios. A identidade da função nunca muda.

typescript
export function useStableToken(): {
  stableGetToken: (options?) => Promise<string | null>
  stableGetTokenNonNull: (options?) => Promise<string | null>
  isLoaded: boolean
  isSignedIn: boolean | null | undefined
}

stableGetToken — retorna o token atual do Clerk. Pode retornar null durante rotação.

stableGetTokenNonNull — igual ao anterior, mas retenta até 3× com 300ms de espera entre tentativas. Use quando o código não tem retry próprio (ex: serviços com axios). Retorna null apenas após esgotar as 3 tentativas.

Regra obrigatória: stableGetToken NUNCA deve ser adicionado ao array de dependências de useEffect.

typescript
// Padrão correto:
const { stableGetToken, isLoaded, isSignedIn } = useStableToken()

useEffect(() => {
  if (!isLoaded || !isSignedIn) return
  async function load() {
    const res = await fetchWithAuth(url, stableGetToken)
    setData(await res.json())
  }
  load()
}, [isLoaded, isSignedIn]) // NUNCA adicionar stableGetToken aqui

useAuthenticatedFetch

Arquivo: hooks/useAuthenticatedFetch.ts | Diretiva: "use client"

Wrapper sobre useStableToken que expõe um fetch pré-autenticado.

typescript
export function useAuthenticatedFetch(): {
  authFetch: (url: string, options?: RequestInit) => Promise<Response>
  isLoaded: boolean
  isSignedIn: boolean | null | undefined
}

authFetch injeta automaticamente Authorization: Bearer <token>. Propaga signal e todas as outras opções de RequestInit. Identidade estável — não precisa de deps no useEffect.

typescript
const { authFetch, isLoaded, isSignedIn } = useAuthenticatedFetch()

useEffect(() => {
  if (!isLoaded || !isSignedIn) return
  authFetch('/api/data').then(r => r.json()).then(setData)
}, [isLoaded, isSignedIn])

useDashboard

Arquivo: hooks/useDashboard.ts

Hook de dados do dashboard com fetch em 2 fases e polling automático.

typescript
export function useDashboard(): {
  dailyTotal: DashboardTotal
  history: HistoryItem[]
  recentSales: RecentSale[]
  storeComparison: StoreComparison[]
  intradayHistory: IntradayPoint[]
  topSellers: TopSeller[]
  rfmOverview: RfmOverview | null
  loading: boolean        // secundário (após KPIs)
  loadingKpis: boolean    // fase 1
  syncing: boolean
  error: string | null
  triggerSync: () => Promise<void>
  refetch: () => void
}

Estratégia de 2 fases:

Fase 1 (rápida — desbloqueia KPI cards imediatamente):
  GET /sales/dashboard-total → setDailyTotal() → setLoadingKpis(false)

Fase 2 (paralela — restante dos dados):
  Promise.all([
    GET /sales/dashboard-history,
    GET /sales/recent-sales,
    GET /sales/store-comparison,
    GET /sales/intraday-history,
    GET /sales/top-sellers,
    GET /rfm/overview?referencePeriod=YYYY-MM-01,
  ])

Polling: polling silencioso a cada 2 minutos (POLL_INTERVAL_MS = 120_000). O flag silent = true evita reinicializar os estados de loading durante o polling.

triggerSync: dispara POST /sales/trigger-sync e após 8 segundos faz fetchAll(silent=true) para atualizar os dados com os novos registros do ERP.

Interfaces exportadas:

typescript
interface StoreComparison {
  storeId: string; tradeName: string; code: string
  today: number; yesterday: number; variation: number
  count: number; lastSale: string | null
}

interface TopSeller {
  rank: number; name: string; store: string
  total: number; count: number
}

interface IntradayPoint {
  hour: string; hoje: number; ontem: number; isFuture: boolean
}

interface RfmSegmentProfile {
  segment: string; count: number; avgSpent: number
  avgOrders: number; totalRevenue: number; avgRecency: number
}

interface RfmOverview {
  kpis: { total: number; new: number; recurring: number }
  profiles: RfmSegmentProfile[]
  summary: { count: number; totalRevenue: number; avgRecency: number; avgOrders: number; avgSpent: number }
}

useDataGuard

Arquivo: hooks/useDataGuard.ts

Detecta dados vazios/zerados após o loading terminar e oferece retry automático.

typescript
interface UseDataGuardOptions {
  loading: boolean           // se ainda está carregando
  error?: string | null      // mensagem de erro externo
  checkPassed: boolean       // true = dados reais foram carregados
  onRetry?: () => void       // função para re-disparar fetch
  autoRetryCount?: number    // tentativas automáticas (default: 1)
  retryDelay?: number        // ms entre retries (default: 1500)
}

export function useDataGuard(options): {
  dataWarning: 'empty' | 'error' | null
  retrigger: () => void
  retryAttempts: number
}

Fluxo:

loading=true  → limpa aviso 'empty' (mantém 'error')
error != null → dataWarning = 'error' (imediato)
loading=false + checkPassed=true  → dataWarning = null
loading=false + checkPassed=false →
  autoRetryRef < autoRetryCount → agenda retry (retryDelay ms)
  autoRetryRef >= autoRetryCount → dataWarning = 'empty' após 300ms

O delay de 300ms antes de mostrar 'empty' evita "piscada" quando dados chegam quase simultaneamente.


Contextos (frontend/app/contexts/)


RBACContext

Arquivo: app/contexts/RBACContext.tsx | Diretiva: "use client"

Contexto global de autenticação e controle de acesso baseado em papéis.

Interfaces

typescript
interface Role {
  id: string
  name: string
  level: number        // 0 = Super Admin (acesso total)
  permissions: string[]
  readOnly: boolean
}

interface UserProfile {
  id: string
  email: string
  name: string
  role: Role
  tenantId: string
  organizationName: string
}

RBACProvider

Carrega o perfil do usuário autenticado via GET /users/me na montagem. Usa useStableToken() para evitar re-fetch na rotação do JWT.

Comportamento de loading:

  • Bloqueia renderização do LayoutShell enquanto carrega
  • Se isSignedIn=false e não há perfil em memória → loading=false imediato
  • Se isSignedIn=false com perfil já carregado → mantém o perfil (estado transitório do Clerk)
  • Em erro de rede → mantém perfil existente (não limpa UI)
  • Se API retornar 403 → força signOut({ redirectUrl: '/sign-in' })

useRBAC

typescript
export const useRBAC = () => useContext(RBACContext)

// Valores expostos:
{
  profile: UserProfile | null
  loading: boolean
  hasPermission: (permission: string) => boolean
  isReadOnly: boolean
  refreshProfile: () => Promise<void>
}

hasPermission(permission):

  • profile.role.level === 0 → sempre true (Super Admin bypassa todas as permissões)
  • Caso contrário → verifica profile.role.permissions.includes(permission)

isReadOnly:

  • true apenas quando role.readOnly === true AND role.level !== 0
  • Super Admin nunca é read-only mesmo com a flag setada

refreshProfile(): re-fetch de /users/me e atualiza estado. Chamado após mudanças de role pelo painel de configurações.


SidebarContext

Arquivo: app/contexts/SidebarContext.tsx | Diretiva: "use client"

Gerencia o estado da sidebar (colapsado/expandido no desktop, aberto/fechado no mobile).

typescript
type SidebarContextType = {
  isCollapsed: boolean    // desktop: sidebar colapsada (w-20)
  toggleSidebar: () => void
  isMobileOpen: boolean   // mobile: drawer aberto
  openMobile: () => void
  closeMobile: () => void
}

export function useSidebar(): SidebarContextType
// Lança Error se chamado fora de SidebarProvider

Integração com LayoutShell:

  • isCollapsed controla a margem do conteúdo principal: lg:ml-20 vs lg:ml-64
  • isMobileOpen controla o Mobile Drawer overlay com animação slide-in-from-left
  • closeMobile() é chamado pelo backdrop click e pelo onClose do Sidebar no modo drawer

Documentação interna — Galdix CRM