Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions src/components/auth/LegalFooter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
// LegalFooter usa <a> nativo para abrir os links legais em nova aba,
// preservando o estado do formulário de login. Não usar <Link> aqui.
import { cn } from '@/lib/utils';

interface LegalFooterProps {
Expand All @@ -14,6 +15,7 @@ interface LegalFooterProps {
* - Responsivo: tipografia e espaçamento se adaptam em telas pequenas.
* - Contraste melhorado em relação à versão anterior (text-muted-foreground/80).
* - Inclui links clicáveis para Termos de Uso e Política de Privacidade.
* - Links abrem em nova aba (target="_blank") para não interromper o login.
*/
export function LegalFooter({ className, withDivider = true }: LegalFooterProps) {
const year = new Date().getFullYear();
Expand All @@ -37,21 +39,25 @@ export function LegalFooter({ className, withDivider = true }: LegalFooterProps)
className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-[9px] opacity-90 sm:text-[10px]"
aria-label="Links legais"
>
<Link
to="/termos"
<a
href="/termos"
target="_blank"
rel="noopener noreferrer"
className="rounded font-medium text-white/60 transition-colors hover:text-white hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-[#030508]"
>
Termos de Uso
</Link>
</a>
<span aria-hidden="true" className="text-white/20">
</span>
<Link
to="/privacidade"
<a
href="/privacidade"
target="_blank"
rel="noopener noreferrer"
className="rounded font-medium text-white/60 transition-colors hover:text-white hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-[#030508]"
>
Política de Privacidade
</Link>
</a>
</nav>

<p className="text-center text-[9px] font-medium text-white/40 opacity-90 sm:text-[10px]">
Expand Down
70 changes: 53 additions & 17 deletions src/components/quotes/QuoteKanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,11 @@ const columns: Column[] = [
interface QuoteCardProps {
quote: Quote;
isDragging?: boolean;
/** Quando true, o card pulsa indicando que uma mutação está em andamento. */
isSaving?: boolean;
}

function QuoteCard({ quote, isDragging }: QuoteCardProps) {
function QuoteCard({ quote, isDragging, isSaving }: QuoteCardProps) {
const navigate = useNavigate();

const formatCurrency = (value: number) => {
Expand All @@ -120,6 +122,7 @@ function QuoteCard({ quote, isDragging }: QuoteCardProps) {
'cursor-grab transition-all duration-200 active:cursor-grabbing',
'border-border/50 bg-card hover:bg-accent/50',
isDragging && 'opacity-50 shadow-lg ring-2 ring-primary',
isSaving && 'opacity-70 ring-2 ring-primary/50 animate-pulse cursor-wait',
quote.status === 'pending_approval' && 'border-amber-500/40 ring-1 ring-amber-500/10',
)}
>
Expand Down Expand Up @@ -175,13 +178,14 @@ function QuoteCard({ quote, isDragging }: QuoteCardProps) {

interface SortableQuoteCardProps {
quote: Quote;
isSaving?: boolean;
}

function getSortableQuoteId(quote: Quote) {
return quote.id ?? `quote-${quote.quote_number}`;
}

function SortableQuoteCard({ quote }: SortableQuoteCardProps) {
function SortableQuoteCard({ quote, isSaving }: SortableQuoteCardProps) {
Comment on lines 184 to +188
const sortableId = getSortableQuoteId(quote);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: sortableId,
Expand All @@ -194,7 +198,7 @@ function SortableQuoteCard({ quote }: SortableQuoteCardProps) {

return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<QuoteCard quote={quote} isDragging={isDragging} />
<QuoteCard quote={quote} isDragging={isDragging} isSaving={isSaving} />
</div>
);
}
Expand All @@ -203,9 +207,11 @@ interface KanbanColumnProps {
column: Column;
quotes: Quote[];
totalValue: number;
/** Set de IDs dos cards em processo de salvamento (mostra pulse). */
savingIds: Set<string>;
}

function KanbanColumn({ column, quotes, totalValue }: KanbanColumnProps) {
function KanbanColumn({ column, quotes, totalValue, savingIds }: KanbanColumnProps) {
const Icon = column.icon;
const sortableQuoteIds = quotes.map(getSortableQuoteId);

Expand Down Expand Up @@ -239,7 +245,11 @@ function KanbanColumn({ column, quotes, totalValue }: KanbanColumnProps) {
<SortableContext items={sortableQuoteIds} strategy={verticalListSortingStrategy}>
<div className="space-y-2 p-1">
{quotes.map((quote) => (
<SortableQuoteCard key={getSortableQuoteId(quote)} quote={quote} />
<SortableQuoteCard
key={getSortableQuoteId(quote)}
quote={quote}
isSaving={savingIds.has(quote.id ?? '')}
/>
))}
{quotes.length === 0 && (
<div className="rounded-lg border border-dashed border-border/50 py-8 text-center text-sm text-muted-foreground">
Expand All @@ -260,6 +270,8 @@ interface QuoteKanbanBoardProps {
export function QuoteKanbanBoard({ quotes }: QuoteKanbanBoardProps) {
const { updateQuoteStatus } = useQuotes();
const [activeQuote, setActiveQuote] = useState<Quote | null>(null);
/** IDs dos cards que estão sendo salvos — exibe animate-pulse enquanto a mutação está pendente. */
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());

const sensors = useSensors(
useSensor(PointerSensor, {
Expand Down Expand Up @@ -362,20 +374,43 @@ export function QuoteKanbanBoard({ quotes }: QuoteKanbanBoardProps) {
}

if (!activeQuote.id) return;
const success = await updateQuoteStatus(activeQuote.id, targetStatus);
if (success) {
toast.success('Status atualizado!', {
description: `Orçamento movido para "${columns.find((c) => c.id === targetStatus)?.title}"`,
});
// 🎉 Celebration when quote is approved
if (targetStatus === 'approved') {
confetti({
particleCount: 80,
spread: 60,
origin: { y: 0.7 },
colors: ['hsl(25, 100%, 50%)', 'hsl(142, 71%, 45%)', 'hsl(217, 91%, 60%)'],

// Marca o card como "salvando" para feedback visual imediato (animate-pulse)
const cardId = activeQuote.id;
setSavingIds((prev) => new Set([...prev, cardId]));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Concurrent status updates are still possible because saving state is visual-only and not enforced before mutation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/quotes/QuoteKanbanBoard.tsx, line 380:

<comment>Concurrent status updates are still possible because saving state is visual-only and not enforced before mutation.</comment>

<file context>
@@ -362,20 +374,43 @@ export function QuoteKanbanBoard({ quotes }: QuoteKanbanBoardProps) {
+
+      // Marca o card como "salvando" para feedback visual imediato (animate-pulse)
+      const cardId = activeQuote.id;
+      setSavingIds((prev) => new Set([...prev, cardId]));
+
+      try {
</file context>


try {
const success = await updateQuoteStatus(cardId, targetStatus);
if (success) {
toast.success('Status atualizado!', {
description: `Orçamento movido para "${columns.find((c) => c.id === targetStatus)?.title}"`,
});
// 🎉 Celebration when quote is approved
if (targetStatus === 'approved') {
confetti({
particleCount: 80,
spread: 60,
origin: { y: 0.7 },
colors: ['hsl(25, 100%, 50%)', 'hsl(142, 71%, 45%)', 'hsl(217, 91%, 60%)'],
});
}
} else {
// Falha silenciosa do updateQuoteStatus — mostra rollback visual
toast.error('Erro ao atualizar status', {
description: 'O card foi revertido para a posição original. Tente novamente.',
});
}
} catch {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: If updateQuoteStatus internally catches all exceptions and returns boolean, this catch block is unreachable dead code. It misleads readers into thinking network errors are handled here, when in reality they surface as success = false in the else branch above. Either remove the catch or ensure updateQuoteStatus re-throws on network errors so this block can actually execute.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/quotes/QuoteKanbanBoard.tsx, line 403:

<comment>If `updateQuoteStatus` internally catches all exceptions and returns `boolean`, this `catch` block is unreachable dead code. It misleads readers into thinking network errors are handled here, when in reality they surface as `success = false` in the `else` branch above. Either remove the `catch` or ensure `updateQuoteStatus` re-throws on network errors so this block can actually execute.</comment>

<file context>
@@ -362,20 +374,43 @@ export function QuoteKanbanBoard({ quotes }: QuoteKanbanBoardProps) {
+            description: 'O card foi revertido para a posição original. Tente novamente.',
           });
         }
+      } catch {
+        toast.error('Falha ao salvar', {
+          description: 'Verifique sua conexão e tente novamente.',
</file context>

toast.error('Falha ao salvar', {
description: 'Verifique sua conexão e tente novamente.',
});
} finally {
Comment on lines +403 to +407
// Remove o indicador de salvamento independente do resultado
setSavingIds((prev) => {
const next = new Set(prev);
next.delete(cardId);
return next;
});
}
}
};
Expand All @@ -395,6 +430,7 @@ export function QuoteKanbanBoard({ quotes }: QuoteKanbanBoardProps) {
column={column}
quotes={quotesByStatus[column.id]}
totalValue={totalsByStatus[column.id]}
savingIds={savingIds}
/>
))}
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/hooks/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
// Exporting all hooks from common
export * from '@/hooks/common/useAppBootstrap';
export * from '@/hooks/common/useBulkSelection';
export * from '@/hooks/common/useConsecutiveFailures';
Expand All @@ -8,6 +7,8 @@ export * from '@/hooks/common/useDebouncedFilters';
export * from '@/hooks/common/useEntitySelectionMode';
export * from '@/hooks/common/useGenericFuzzySearch';
export * from '@/hooks/common/useInfiniteScroll';
export * from '@/hooks/common/useNetworkStatus';
export * from '@/hooks/common/useOfflineGuard';
export * from '@/hooks/common/useOrgData';
export * from '@/hooks/common/useSearch';
export * from '@/hooks/common/useSearchHistory';
Expand Down
28 changes: 28 additions & 0 deletions src/hooks/common/useNetworkStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* useNetworkStatus — monitora o estado da conexão de rede.
*
* Retorna { isOnline, isOffline } com atualização reativa
* baseada nos eventos window.online / window.offline.
*/
import { useState, useEffect } from 'react';

export function useNetworkStatus() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true,
);

useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);

window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);

return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);

return { isOnline, isOffline: !isOnline };
}
53 changes: 53 additions & 0 deletions src/hooks/common/useOfflineGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* useOfflineGuard — hook para proteger operações de escrita quando offline.
*
* Uso:
* const { isOffline, guardedMutate } = useOfflineGuard();
*
* // Em vez de chamar mutate() diretamente:
* guardedMutate(() => mutate(payload));
*
* Também expõe `isOffline` para desabilitar botões de submit:
* <Button disabled={isOffline || isLoading}>Salvar</Button>
*/
import { useState, useEffect, useCallback } from 'react';
import { toast } from 'sonner';

export function useOfflineGuard() {
const [isOnline, setIsOnline] = useState(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: useOfflineGuard duplicates the existing useNetworkStatus implementation instead of reusing it, creating avoidable maintenance divergence risk.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/hooks/common/useOfflineGuard.ts, line 17:

<comment>`useOfflineGuard` duplicates the existing `useNetworkStatus` implementation instead of reusing it, creating avoidable maintenance divergence risk.</comment>

<file context>
@@ -0,0 +1,53 @@
+import { toast } from 'sonner';
+
+export function useOfflineGuard() {
+  const [isOnline, setIsOnline] = useState(
+    typeof navigator !== 'undefined' ? navigator.onLine : true,
+  );
</file context>

typeof navigator !== 'undefined' ? navigator.onLine : true,
);

useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);

const isOffline = !isOnline;

/**
* Executa `fn` somente se online.
* Se offline, exibe toast explicativo e retorna false.
*/
const guardedMutate = useCallback(
<T>(fn: () => T | Promise<T>): T | Promise<T> | false => {
if (isOffline) {
toast.error('Sem conexão com a internet', {
description: 'Verifique sua conexão Wi-Fi ou dados móveis e tente novamente.',
duration: 5000,
});
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: guardedMutate returns false as a sentinel when offline, but the generic T allows boolean, making the return ambiguous for callers — a legitimate false from the mutation is indistinguishable from the guard blocking execution. Consider returning undefined (changing the signature to T | Promise<T> | undefined) or throwing an error to provide unambiguous signaling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/hooks/common/useOfflineGuard.ts, line 45:

<comment>`guardedMutate` returns `false` as a sentinel when offline, but the generic `T` allows `boolean`, making the return ambiguous for callers — a legitimate `false` from the mutation is indistinguishable from the guard blocking execution. Consider returning `undefined` (changing the signature to `T | Promise<T> | undefined`) or throwing an error to provide unambiguous signaling.</comment>

<file context>
@@ -0,0 +1,53 @@
+          description: 'Verifique sua conexão Wi-Fi ou dados móveis e tente novamente.',
+          duration: 5000,
+        });
+        return false;
+      }
+      return fn();
</file context>

}
return fn();
},
[isOffline],
);
Comment on lines +34 to +50

return { isOnline, isOffline, guardedMutate };
}
33 changes: 33 additions & 0 deletions src/lib/query-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ const PREFIX_STALE_MAP: Record<string, number> = {
// REALTIME — near real-time signals
'connection-status': CACHE_TIMES.REALTIME,
'bridge-health': CACHE_TIMES.REALTIME,

// BI / INTELLIGENCE — dashboards analíticos (5min = DYNAMIC para dados operacionais)
// Evita refetch completo a cada navegação para a página de BI.
'market-intelligence': CACHE_TIMES.DYNAMIC,
'bi-kpis': CACHE_TIMES.DYNAMIC,
'commercial-intelligence': CACHE_TIMES.DYNAMIC,
'intelligence-kpis': CACHE_TIMES.DYNAMIC,
'intelligence-chart': CACHE_TIMES.DYNAMIC,
'trending-products': CACHE_TIMES.DYNAMIC,
'category-ranking': CACHE_TIMES.DYNAMIC,
'supplier-sales': CACHE_TIMES.DYNAMIC,
'sales-overview': CACHE_TIMES.DYNAMIC,
'unmet-demand': CACHE_TIMES.DYNAMIC,
'hot-searches': CACHE_TIMES.DYNAMIC,
'conversion-funnel': CACHE_TIMES.DYNAMIC,
'trends-heatmap': CACHE_TIMES.DYNAMIC,
'trends-forecast': CACHE_TIMES.DYNAMIC,
'trends-insights': CACHE_TIMES.DYNAMIC,
'top-categories': CACHE_TIMES.DYNAMIC,
'mockup-history': CACHE_TIMES.DYNAMIC,
};

const PREFIX_GC_MAP: Record<string, number> = {
Expand Down Expand Up @@ -178,6 +198,19 @@ export const STABLE_DATA_QUERY_OPTIONS = {
refetchOnMount: false,
} as const;



// ─────────────────────────────────────────────────────────────────────────────
// BI / Intelligence query options — dashboards analíticos
// Cache de 5 min evita refetch completo a cada navegação para páginas de BI.
// ─────────────────────────────────────────────────────────────────────────────
export const BI_QUERY_OPTIONS = {
staleTime: CACHE_TIMES.DYNAMIC,
gcTime: GC_TIMES.DEFAULT,
refetchOnWindowFocus: false,
refetchOnMount: false,
} as const;

// ─────────────────────────────────────────────────────────────────────────────
// Default query options
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
30 changes: 28 additions & 2 deletions src/pages/auth/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export default function Auth() {
const [blockedIP, setBlockedIP] = useState<string | null>(null);
const [currentIP, setCurrentIP] = useState<string | null>(null);
const [geoLocation, setGeoLocation] = useState<string | null>(null);

/** Segundos restantes para rate limit. 0 = sem bloqueio. */
const [rateLimitCountdown, setRateLimitCountdown] = useState(0);
const rateLimitTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);

Comment on lines +89 to +92
// Fallback social → email/senha: mensagem amigável quando OAuth falha.
const [socialError, setSocialError] = useState<OAuthErrorCopy | null>(null);

Expand Down Expand Up @@ -256,7 +261,23 @@ export default function Auth() {
title = 'Acesso Temporariamente Suspenso';
description =
'Detectamos muitas tentativas seguidas. Por segurança, sua conta foi bloqueada por alguns minutos.';
hint = 'Tome um café e tente novamente em instantes.';
// Extrai o tempo de espera da mensagem do Supabase (ex: "after 47 seconds")
const secondsMatch = error.message.match(/after (\d+) seconds?/i);
const waitSeconds = secondsMatch ? parseInt(secondsMatch[1], 10) : 60;
hint = `Aguarde ${waitSeconds} segundos antes de tentar novamente.`;

// Iniciar countdown visual
if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
setRateLimitCountdown(waitSeconds);
rateLimitTimerRef.current = setInterval(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The new rate-limit countdown interval is not cleaned up on component unmount, which can leak timers during navigation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/pages/auth/Auth.tsx, line 272:

<comment>The new rate-limit countdown interval is not cleaned up on component unmount, which can leak timers during navigation.</comment>

<file context>
@@ -256,7 +261,23 @@ export default function Auth() {
+          // Iniciar countdown visual
+          if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
+          setRateLimitCountdown(waitSeconds);
+          rateLimitTimerRef.current = setInterval(() => {
+            setRateLimitCountdown((prev) => {
+              if (prev <= 1) {
</file context>

setRateLimitCountdown((prev) => {
if (prev <= 1) {
if (rateLimitTimerRef.current) clearInterval(rateLimitTimerRef.current);
return 0;
}
return prev - 1;
});
}, 1000);
} else if (
error.status === 0 ||
error.message.includes('network') ||
Expand Down Expand Up @@ -746,13 +767,18 @@ export default function Auth() {
className={authButtonClass(
'h-12 w-full rounded-xl border border-white/10 bg-blue-600 text-base text-white shadow-lg shadow-blue-500/25 hover:bg-blue-700 hover:shadow-xl hover:shadow-blue-500/40 active:scale-[0.98]',
)}
disabled={isSubmitting}
disabled={isSubmitting || rateLimitCountdown > 0}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Iniciando Sistemas...
</>
) : rateLimitCountdown > 0 ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Aguarde {rateLimitCountdown}s...
</>
) : (
'Entrar na Plataforma'
)}
Expand Down
Loading
Loading