Tema
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 aquiuseAuthenticatedFetch
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 300msO 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
LayoutShellenquanto carrega - Se
isSignedIn=falsee não há perfil em memória →loading=falseimediato - Se
isSignedIn=falsecom 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çasignOut({ 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→ sempretrue(Super Admin bypassa todas as permissões)- Caso contrário → verifica
profile.role.permissions.includes(permission)
isReadOnly:
trueapenas quandorole.readOnly === trueANDrole.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 SidebarProviderIntegração com LayoutShell:
isCollapsedcontrola a margem do conteúdo principal:lg:ml-20vslg:ml-64isMobileOpencontrola o Mobile Drawer overlay com animaçãoslide-in-from-leftcloseMobile()é chamado pelo backdrop click e peloonClosedo Sidebar no modo drawer