diff --git a/src/components/kit-builder/kit-summary/KitCompositionCard.tsx b/src/components/kit-builder/kit-summary/KitCompositionCard.tsx index 706649fa4..1fe57be80 100644 --- a/src/components/kit-builder/kit-summary/KitCompositionCard.tsx +++ b/src/components/kit-builder/kit-summary/KitCompositionCard.tsx @@ -60,11 +60,19 @@ export function KitCompositionCard({ kitState, kitQuantity, stockByProduct }: Ki {item.material ? ` • ${item.material}` : ''} {item.isOptional && Opcional}

- {stockByProduct.has(item.id) && ( - = item.quantity * kitQuantity ? 'secondary' : 'destructive'} className="text-[10px] px-1.5 py-0"> - {stockByProduct.get(item.id)! >= item.quantity * kitQuantity ? `${stockByProduct.get(item.id)} em estoque` : `⚠ ${stockByProduct.get(item.id)} disponível`} - - )} + {(() => { + const stockQty = stockByProduct.get(item.id); + if (stockQty === undefined) return null; + const enough = stockQty >= item.quantity * kitQuantity; + return ( + + {enough ? `${stockQty} em estoque` : `⚠ ${stockQty} disponível`} + + ); + })()}
diff --git a/src/components/mockup/ProductSearchCombobox.tsx b/src/components/mockup/ProductSearchCombobox.tsx index 30e3bd95f..8e9279db6 100644 --- a/src/components/mockup/ProductSearchCombobox.tsx +++ b/src/components/mockup/ProductSearchCombobox.tsx @@ -107,16 +107,24 @@ export function ProductSearchCombobox({
{/* Product thumbnail */}
- {getProductImage(selectedProduct) ? ( - {selectedProduct.name} - ) : ( -
- -
- )} + {(() => { + const img = getProductImage(selectedProduct); + if (img) { + return ( + {selectedProduct.name} + ); + } + return ( +
+ +
+ ); + })()}
{/* Product info */} @@ -208,16 +216,24 @@ export function ProductSearchCombobox({ {/* Product thumbnail */}
- {getProductImage(product) ? ( - {product.name} - ) : ( -
- -
- )} + {(() => { + const img = getProductImage(product); + if (img) { + return ( + {product.name} + ); + } + return ( +
+ +
+ ); + })()}
{/* Product info */} diff --git a/src/components/pdf/proposal/LogoWithTransparentBg.tsx b/src/components/pdf/proposal/LogoWithTransparentBg.tsx index 781e0d609..2128bb5f9 100644 --- a/src/components/pdf/proposal/LogoWithTransparentBg.tsx +++ b/src/components/pdf/proposal/LogoWithTransparentBg.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState } from 'react'; // ── Module-level cache so the image is processed once and reused instantly ── const logoCache = new Map(); @@ -10,8 +10,10 @@ const logoPromises = new Map>(); * Result is cached for the lifetime of the page. */ export function processLogoTransparent(src: string): Promise { - if (logoCache.has(src)) return Promise.resolve(logoCache.get(src)!); - if (logoPromises.has(src)) return logoPromises.get(src)!; + const cached = logoCache.get(src); + if (cached) return Promise.resolve(cached); + const inflight = logoPromises.get(src); + if (inflight) return inflight; const promise = fetch(src) .then((res) => res.blob()) @@ -21,28 +23,36 @@ export function processLogoTransparent(src: string): Promise { const objectUrl = URL.createObjectURL(blob); const img = new Image(); img.onload = () => { - const canvas = document.createElement("canvas"); + const canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; - const ctx = canvas.getContext("2d"); - if (!ctx) { resolve(src); return; } + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(src); + return; + } ctx.drawImage(img, 0, 0); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const d = imageData.data; for (let i = 0; i < d.length; i += 4) { - const r = d[i], g = d[i + 1], b = d[i + 2]; + const r = d[i], + g = d[i + 1], + b = d[i + 2]; // Threshold 235 catches off-white anti-alias edges if (r > 235 && g > 235 && b > 235) d[i + 3] = 0; } ctx.putImageData(imageData, 0, 0); URL.revokeObjectURL(objectUrl); - const dataUrl = canvas.toDataURL("image/png"); + const dataUrl = canvas.toDataURL('image/png'); logoCache.set(src, dataUrl); resolve(dataUrl); }; - img.onerror = () => { URL.revokeObjectURL(objectUrl); resolve(src); }; + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + resolve(src); + }; img.src = objectUrl; - }) + }), ) .catch(() => src); @@ -58,11 +68,12 @@ interface Props { export function LogoWithTransparentBg({ src, style, alt }: Props) { // Immediately use cached result if available (no flash) - const [dataUrl, setDataUrl] = useState(() => logoCache.get(src) ?? ""); + const [dataUrl, setDataUrl] = useState(() => logoCache.get(src) ?? ''); useEffect(() => { - if (logoCache.has(src)) { - setDataUrl(logoCache.get(src)!); + const cached = logoCache.get(src); + if (cached) { + setDataUrl(cached); return; } processLogoTransparent(src).then(setDataUrl); @@ -70,5 +81,5 @@ export function LogoWithTransparentBg({ src, style, alt }: Props) { if (!dataUrl) return
; - return {alt; + return {alt; } diff --git a/src/components/quotes/QuoteBuilderNavigation.tsx b/src/components/quotes/QuoteBuilderNavigation.tsx index 5995ccad7..88df7d0d2 100644 --- a/src/components/quotes/QuoteBuilderNavigation.tsx +++ b/src/components/quotes/QuoteBuilderNavigation.tsx @@ -1,5 +1,5 @@ -import { Button } from "@/components/ui/button"; -import { QuoteBuilderStep } from "./QuoteBuilderStepper"; +import { Button } from '@/components/ui/button'; +import { type QuoteBuilderStep } from './QuoteBuilderStepper'; interface QuoteBuilderNavigationProps { currentStep: QuoteBuilderStep; @@ -8,21 +8,23 @@ interface QuoteBuilderNavigationProps { isLastStep: boolean; } -export function QuoteBuilderNavigation({ currentStep, onNext, onPrev, isLastStep }: QuoteBuilderNavigationProps) { +export function QuoteBuilderNavigation({ + currentStep, + onNext, + onPrev, + isLastStep, +}: QuoteBuilderNavigationProps) { return (
- -
diff --git a/src/components/security/SecurityDashboard.tsx b/src/components/security/SecurityDashboard.tsx index 1fb156998..a0bff4874 100644 --- a/src/components/security/SecurityDashboard.tsx +++ b/src/components/security/SecurityDashboard.tsx @@ -7,13 +7,35 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { useAuth } from '@/contexts/AuthContext'; import { supabase } from '@/integrations/supabase/client'; import { - Shield, ShieldCheck, ShieldAlert, ShieldX, Key, Monitor, Globe, - Lock, Unlock, AlertTriangle, CheckCircle2, XCircle, Clock, Activity, - Eye, Bell, History, MapPin, Users, + Shield, + ShieldCheck, + ShieldAlert, + ShieldX, + Key, + Monitor, + Globe, + Lock, + Unlock, + AlertTriangle, + CheckCircle2, + XCircle, + Clock, + Activity, + Eye, + Bell, + History, + MapPin, + Users, } from 'lucide-react'; import { formatDistanceToNow, format } from 'date-fns'; import { ptBR } from 'date-fns/locale'; @@ -23,7 +45,10 @@ import { GeoBlockingManager } from './GeoBlockingManager'; import { KnownDevicesManager } from '@/components/auth/KnownDevicesManager'; import { PushNotificationSettings } from './PushNotificationSettings'; import { - useSecurityData, getScoreColor, getScoreProgressColor, getScoreLabel, + useSecurityData, + getScoreColor, + getScoreProgressColor, + getScoreLabel, type UserProfile, } from './useSecurityData'; @@ -41,50 +66,91 @@ export function SecurityDashboard() { const effectiveUserId = selectedUserId || user?.id; const isManagingOther = !!selectedUserId && selectedUserId !== user?.id; - const selectedUser = users.find(u => u.user_id === selectedUserId); + const selectedUser = users.find((u) => u.user_id === selectedUserId); - const { metrics, loginAttempts, notifications, is2FAEnabled, allowedIPs } = - useSecurityData(effectiveUserId, isManagingOther, selectedUserId); + const { metrics, loginAttempts, notifications, is2FAEnabled, allowedIPs } = useSecurityData( + effectiveUserId, + isManagingOther, + selectedUserId, + ); useEffect(() => { if (isAdmin) { - supabase.from('profiles').select('user_id, full_name, email') - .eq('is_active', true).order('full_name') - .then(({ data }) => { if (data) setUsers(data); }); + supabase + .from('profiles') + .select('user_id, full_name, email') + .eq('is_active', true) + .order('full_name') + .then(({ data }) => { + if (data) setUsers(data); + }); } }, [isAdmin]); const recommendations = []; - if (!is2FAEnabled) recommendations.push({ icon: , title: 'Ativar autenticação de dois fatores', description: isManagingOther ? 'Este usuário não possui 2FA ativado' : 'Adiciona uma camada extra de segurança à sua conta', priority: 'high' }); - if (allowedIPs.length === 0) recommendations.push({ icon: , title: 'Configurar restrição de IP', description: 'Limite o acesso por endereços IP específicos', priority: 'medium' }); - if (metrics.failedLoginAttempts > 3) recommendations.push({ icon: , title: 'Revisar tentativas de login falhas', description: 'Foram detectadas várias tentativas de login sem sucesso', priority: 'high' }); + if (!is2FAEnabled) + recommendations.push({ + icon: , + title: 'Ativar autenticação de dois fatores', + description: isManagingOther + ? 'Este usuário não possui 2FA ativado' + : 'Adiciona uma camada extra de segurança à sua conta', + priority: 'high', + }); + if (allowedIPs.length === 0) + recommendations.push({ + icon: , + title: 'Configurar restrição de IP', + description: 'Limite o acesso por endereços IP específicos', + priority: 'medium', + }); + if (metrics.failedLoginAttempts > 3) + recommendations.push({ + icon: , + title: 'Revisar tentativas de login falhas', + description: 'Foram detectadas várias tentativas de login sem sucesso', + priority: 'high', + }); return (
{/* Admin User Selector */} {isAdmin && ( - +
- Gerenciar segurança de: + Gerenciar segurança de:
- setSelectedUserId(v === user?.id ? null : v)} + > + + + {users.map((u) => (
{u.full_name || 'Sem nome'} - ({u.email}) - {u.user_id === user?.id && Você} + ({u.email}) + {u.user_id === user?.id && ( + + Você + + )}
))}
- {isManagingOther && Gerenciando: {selectedUser?.full_name || selectedUser?.email}} + {isManagingOther && ( + + Gerenciando: {selectedUser?.full_name || selectedUser?.email} + + )}
@@ -94,28 +160,65 @@ export function SecurityDashboard() {
- Pontuação de Segurança - {isManagingOther ? `Avaliação de segurança de ${selectedUser?.full_name || selectedUser?.email}` : 'Avaliação geral da segurança da sua conta'} + + + Pontuação de Segurança + + + {isManagingOther + ? `Avaliação de segurança de ${selectedUser?.full_name || selectedUser?.email}` + : 'Avaliação geral da segurança da sua conta'} +
-
+
{getScoreIcon(metrics.score)} - {metrics.score}% - = 60 ? 'default' : 'destructive'} className="mt-1">{getScoreLabel(metrics.score)} + + {metrics.score}% + + = 60 ? 'default' : 'destructive'} className="mt-1"> + {getScoreLabel(metrics.score)} +
-
Progresso da segurança{metrics.score}%
-
-
+
+ Progresso da segurança + {metrics.score}% +
+
+
-
{is2FAEnabled ? : }MFA {is2FAEnabled ? 'ativo' : 'inativo'}
-
{allowedIPs.length > 0 ? : }{allowedIPs.length} IPs permitidos
-
{metrics.knownDevicesCount} dispositivos
-
{metrics.recentLoginAttempts} logins recentes
+
+ {is2FAEnabled ? ( + + ) : ( + + )} + MFA {is2FAEnabled ? 'ativo' : 'inativo'} +
+
+ {allowedIPs.length > 0 ? ( + + ) : ( + + )} + {allowedIPs.length} IPs permitidos +
+
+ + {metrics.knownDevicesCount} dispositivos +
+
+ + {metrics.recentLoginAttempts} logins recentes +
@@ -123,22 +226,58 @@ export function SecurityDashboard() { - MFA + + + + MFA + +
- {is2FAEnabled ? <>Ativo : <>Inativo} + {is2FAEnabled ? ( + <> + + Ativo + + ) : ( + <> + + Inativo + + )}
-

{is2FAEnabled ? 'Proteção extra ativada' : 'Recomendado ativar'}

+

+ {is2FAEnabled ? 'Proteção extra ativada' : 'Recomendado ativar'} +

- Alertas + + + + Alertas + +
- {metrics.securityAlerts > 0 ? <>{metrics.securityAlerts} : <>0} + {metrics.securityAlerts > 0 ? ( + <> + + + {metrics.securityAlerts} + + + ) : ( + <> + + 0 + + )}
-

{metrics.securityAlerts > 0 ? 'Alertas não lidos' : 'Nenhum alerta pendente'}

+

+ {metrics.securityAlerts > 0 ? 'Alertas não lidos' : 'Nenhum alerta pendente'} +

@@ -146,14 +285,28 @@ export function SecurityDashboard() { {/* Recommendations */} {recommendations.length > 0 && ( - Recomendações de Segurança + + + + Recomendações de Segurança + +
{recommendations.map((rec, idx) => ( -
-
{rec.icon}
-

{rec.title}

{rec.description}

- {rec.priority === 'high' ? 'Alta' : 'Média'} +
+
+ {rec.icon} +
+
+

{rec.title}

+

{rec.description}

+
+ + {rec.priority === 'high' ? 'Alta' : 'Média'} +
))}
@@ -163,64 +316,127 @@ export function SecurityDashboard() { {/* Tabs */} - - Visão Geral - MFA - Dispositivos - IPs - Geo - Push - Histórico + + + + Visão Geral + + + + MFA + + + + Dispositivos + + + + IPs + + + + Geo + + + + Push + + + + Histórico +
- Logins Recentes + + + + Logins Recentes + +
{loginAttempts.slice(0, 10).map((attempt) => ( -
+
- {attempt.success ? : } + {attempt.success ? ( + + ) : ( + + )}

{attempt.ip_address}

-

{formatDistanceToNow(new Date(attempt.created_at), { addSuffix: true, locale: ptBR })}

+

+ {formatDistanceToNow(new Date(attempt.created_at), { + addSuffix: true, + locale: ptBR, + })} +

- {attempt.success ? 'Sucesso' : 'Falha'} + + {attempt.success ? 'Sucesso' : 'Falha'} +
))} - {loginAttempts.length === 0 &&

Nenhum login registrado

} + {loginAttempts.length === 0 && ( +

+ Nenhum login registrado +

+ )}
- Alertas de Segurança + + + + Alertas de Segurança + +
{notifications.map((notif) => ( -
+
- +

{notif.title}

-

{notif.message}

-

{formatDistanceToNow(new Date(notif.created_at), { addSuffix: true, locale: ptBR })}

+

{notif.message}

+

+ {formatDistanceToNow(new Date(notif.created_at), { + addSuffix: true, + locale: ptBR, + })} +

- {!notif.is_read && Novo} + {!notif.is_read && ( + + Novo + + )}
))} {notifications.length === 0 && ( -
- +
+

Tudo seguro!

-

Nenhum alerta de segurança pendente

+

+ Nenhum alerta de segurança pendente +

)}
@@ -230,44 +446,96 @@ export function SecurityDashboard() {
- - - - - + + + + + + + + + + + + + + + - Histórico de Logins - {isManagingOther ? `Tentativas de login de ${selectedUser?.full_name || selectedUser?.email}` : 'Todas as tentativas de login na sua conta'} + + + Histórico de Logins + + + {isManagingOther + ? `Tentativas de login de ${selectedUser?.full_name || selectedUser?.email}` + : 'Todas as tentativas de login na sua conta'} +
{loginAttempts.map((attempt) => ( -
+
{attempt.success ? ( -
+
+ +
) : ( -
+
+ +
)}
- {attempt.success ? 'Login bem-sucedido' : 'Tentativa falha'} - {attempt.success ? 'OK' : 'Falha'} + + {attempt.success ? 'Login bem-sucedido' : 'Tentativa falha'} + + + {attempt.success ? 'OK' : 'Falha'} +
-
- {attempt.ip_address} - {format(new Date(attempt.created_at), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })} +
+ + + {attempt.ip_address} + + + + {format(new Date(attempt.created_at), "dd/MM/yyyy 'às' HH:mm", { + locale: ptBR, + })} +
- {attempt.failure_reason &&

Motivo: {attempt.failure_reason}

} + {attempt.failure_reason && ( +

+ Motivo: {attempt.failure_reason} +

+ )}
))} - {loginAttempts.length === 0 &&

Nenhum login registrado ainda

} + {loginAttempts.length === 0 && ( +

+ Nenhum login registrado ainda +

+ )}
diff --git a/src/hooks/favorites/useFavoritesPageState.ts b/src/hooks/favorites/useFavoritesPageState.ts index a3762070a..5aeaef129 100644 --- a/src/hooks/favorites/useFavoritesPageState.ts +++ b/src/hooks/favorites/useFavoritesPageState.ts @@ -1,11 +1,12 @@ import { useState, useMemo, useEffect } from 'react'; import { useFavoritesStore } from '@/stores/useFavoritesStore'; import { + useEnrichedFavoriteItems, useFavoriteLists, + useFavoritesGlobalShortcuts, useFavoriteTrash, useLegacyFavoritesMigration, } from '@/hooks/favorites'; -import { useEnrichedFavoriteItems, useFavoritesGlobalShortcuts } from "@/hooks/favorites"; import { useProductsContext } from '@/contexts/ProductsContext'; import { useCatalogSelection } from '@/components/catalog/useCatalogSelection'; import { useUndoStack } from '@/hooks/common'; diff --git a/src/hooks/mockup/useMockupGenerator.ts b/src/hooks/mockup/useMockupGenerator.ts index 1e8970839..7146c304e 100644 --- a/src/hooks/mockup/useMockupGenerator.ts +++ b/src/hooks/mockup/useMockupGenerator.ts @@ -11,14 +11,14 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { toast } from 'sonner'; import { needsConversion, ensureSupportedFormat } from '@/lib/image-converter'; import { useAuth } from '@/contexts/AuthContext'; -import { useMockupDraft } from '@/hooks/mockup'; import { useFilteredTechniques, + useMockupDraft, useProductCustomizationOptionsForMockup, - type TechniqueWithLimits, type CustomizationOption, + type TechniqueWithLimits, } from '@/hooks/mockup'; -import { useLogoColorAnalysis, usePositionHistory } from "@/hooks/simulation"; +import { useLogoColorAnalysis, usePositionHistory } from '@/hooks/simulation'; import { useProductsContext } from '@/contexts/ProductsContext'; import { getMockupWizardStep } from '@/components/mockup/mockupWizardStep'; import { showMockupSuccessToast } from '@/components/mockup/MockupSuccessToast'; @@ -37,7 +37,7 @@ import { generateMockupApi, downloadMockupAsPdf, deleteMockupFromDb, -} from "@/hooks/mockup/mockupGenerationService"; +} from '@/hooks/mockup/mockupGenerationService'; // Re-export types for consumers export type { Technique, GeneratedMockup }; diff --git a/src/hooks/quotes/useQuoteBuilderState.ts b/src/hooks/quotes/useQuoteBuilderState.ts index 8f39fcb87..188b24dab 100644 --- a/src/hooks/quotes/useQuoteBuilderState.ts +++ b/src/hooks/quotes/useQuoteBuilderState.ts @@ -5,20 +5,26 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { useNavigate, useParams, useSearchParams, useLocation } from 'react-router-dom'; -import { useAutoSaveQuote, useDiscountApproval, useQuoteItems, useQuotes, useSellerDiscountLimits, type QuoteItem, type QuoteItemPersonalization } from "@/hooks/quotes"; +import { + useAutoSaveQuote, + useDiscountApproval, + useQuoteItems, + useQuotes, + useQuoteTemplates, + useSellerDiscountLimits, + type QuoteItem, + type QuoteItemPersonalization, + type QuoteTemplate, + type QuoteTemplateItem, +} from '@/hooks/quotes'; import { useQuery } from '@tanstack/react-query'; import Fuse from 'fuse.js'; import { format, addDays } from 'date-fns'; import { toast } from 'sonner'; import { formatCurrency as fmtCurrency } from '@/lib/format'; import { validateQuoteForm, QUOTE_FIELD_LABELS } from '@/lib/validations'; -import { - useQuoteTemplates, - type QuoteTemplate, - type QuoteTemplateItem, -} from '@/hooks/quotes'; import { useAuth } from '@/contexts/AuthContext'; -import { findKnownHex, type ExternalVariantStock } from "@/hooks/products"; +import { findKnownHex, type ExternalVariantStock } from '@/hooks/products'; import { useDebounce } from '@/hooks/common'; import type { SelectedCompanyInfo, @@ -170,22 +176,29 @@ export function useQuoteBuilderState() { } }, []); - const handleShippingTypeChange = useCallback((value: string) => { - setShippingType(value); - if (value !== 'fob_pre' && shippingCost !== 0) { - setShippingCost(0); - } - setTimeout(() => { - // Pequeno delay para garantir que o estado foi processado antes de avisar - toast.success(`Frete alterado para: ${ - value === 'cif' ? 'CIF' : - value === 'fob' ? 'FOB' : - 'FOB Pré-negociado' - }`, { - description: value === 'fob_pre' ? 'Lembre-se de informar o valor acordado.' : 'O custo será zerado no orçamento.', - }); - }, 50); - }, [shippingCost]); + const handleShippingTypeChange = useCallback( + (value: string) => { + setShippingType(value); + if (value !== 'fob_pre' && shippingCost !== 0) { + setShippingCost(0); + } + setTimeout(() => { + // Pequeno delay para garantir que o estado foi processado antes de avisar + toast.success( + `Frete alterado para: ${ + value === 'cif' ? 'CIF' : value === 'fob' ? 'FOB' : 'FOB Pré-negociado' + }`, + { + description: + value === 'fob_pre' + ? 'Lembre-se de informar o valor acordado.' + : 'O custo será zerado no orçamento.', + }, + ); + }, 50); + }, + [shippingCost], + ); const [productSearchOpen, setProductSearchOpen] = useState(false); const [productSearch, setProductSearch] = useState(''); @@ -205,7 +218,7 @@ export function useQuoteBuilderState() { const steps: QuoteBuilderStep[] = []; if (clientId && contactId) steps.push('client'); if (paymentMethod && paymentTerms && deliveryTime && shippingType) { - if (shippingType !== 'fob_pre' || (shippingCost > 0)) { + if (shippingType !== 'fob_pre' || shippingCost > 0) { steps.push('conditions'); } } @@ -214,7 +227,16 @@ export function useQuoteBuilderState() { const hasAnyPersonalization = items.some((it) => (it.personalizations?.length ?? 0) > 0); if (items.length > 0 && hasAnyPersonalization) steps.push('personalization'); return steps; - }, [clientId, contactId, items, paymentMethod, paymentTerms, deliveryTime, shippingType, shippingCost]); + }, [ + clientId, + contactId, + items, + paymentMethod, + paymentTerms, + deliveryTime, + shippingType, + shippingCost, + ]); const announce = useCallback((message: string) => { const announcer = document.getElementById('quote-builder-announcer'); @@ -223,75 +245,94 @@ export function useQuoteBuilderState() { } }, []); - const validateStep = useCallback((step: QuoteBuilderStep): boolean => { - switch (step) { - case 'client': - if (!clientId) { - toast.error('Selecione um cliente'); - announce('Erro: Selecione um cliente'); - return false; - } - if (!contactId) { - toast.error('Selecione um contato'); - announce('Erro: Selecione um contato'); - return false; - } - return true; - case 'conditions': { - const errors = validateQuoteForm({ - clientId, - contactId, - paymentMethod, - paymentTerms, - deliveryTime, - shippingType, - shippingCost, - itemsCount: items.length, - }); + const validateStep = useCallback( + (step: QuoteBuilderStep): boolean => { + switch (step) { + case 'client': + if (!clientId) { + toast.error('Selecione um cliente'); + announce('Erro: Selecione um cliente'); + return false; + } + if (!contactId) { + toast.error('Selecione um contato'); + announce('Erro: Selecione um contato'); + return false; + } + return true; + case 'conditions': { + const errors = validateQuoteForm({ + clientId, + contactId, + paymentMethod, + paymentTerms, + deliveryTime, + shippingType, + shippingCost, + itemsCount: items.length, + }); - if (errors.includes('forma_pagamento')) { - toast.error('Selecione a forma de pagamento'); - return false; - } - if (errors.includes('prazo_pagamento')) { - toast.error('Selecione o prazo de pagamento'); - return false; - } - if (errors.includes('prazo_entrega')) { - toast.error('Defina o prazo de entrega'); - return false; - } - if (errors.includes('frete')) { - toast.error('Selecione a modalidade de frete'); - announce('Erro: Selecione a modalidade de frete'); - return false; - } - if (errors.includes('valor_frete')) { - toast.error('Informe o valor do frete pré-negociado'); - return false; + if (errors.includes('forma_pagamento')) { + toast.error('Selecione a forma de pagamento'); + return false; + } + if (errors.includes('prazo_pagamento')) { + toast.error('Selecione o prazo de pagamento'); + return false; + } + if (errors.includes('prazo_entrega')) { + toast.error('Defina o prazo de entrega'); + return false; + } + if (errors.includes('frete')) { + toast.error('Selecione a modalidade de frete'); + announce('Erro: Selecione a modalidade de frete'); + return false; + } + if (errors.includes('valor_frete')) { + toast.error('Informe o valor do frete pré-negociado'); + return false; + } + return true; } - return true; + case 'items': + if (items.length === 0) { + toast.error('Adicione pelo menos um item'); + announce('Erro: Adicione pelo menos um item'); + return false; + } + return true; + case 'personalization': + return true; + case 'review': + return true; + default: + return true; } - case 'items': - if (items.length === 0) { - toast.error('Adicione pelo menos um item'); - announce('Erro: Adicione pelo menos um item'); - return false; - } - return true; - case 'personalization': - return true; - case 'review': - return true; - default: - return true; - } - }, [clientId, contactId, paymentMethod, paymentTerms, deliveryTime, shippingType, shippingCost, items, announce]); + }, + [ + clientId, + contactId, + paymentMethod, + paymentTerms, + deliveryTime, + shippingType, + shippingCost, + items, + announce, + ], + ); const nextStep = useCallback(() => { - const steps: QuoteBuilderStep[] = ['client', 'conditions', 'items', 'personalization', 'review']; + const steps: QuoteBuilderStep[] = [ + 'client', + 'conditions', + 'items', + 'personalization', + 'review', + ]; const currentIndex = steps.indexOf(currentStep); - + if (validateStep(currentStep)) { if (currentIndex < steps.length - 1) { setCurrentStep(steps[currentIndex + 1]); @@ -301,33 +342,48 @@ export function useQuoteBuilderState() { }, [currentStep, validateStep]); const prevStep = useCallback(() => { - const steps: QuoteBuilderStep[] = ['client', 'conditions', 'items', 'personalization', 'review']; + const steps: QuoteBuilderStep[] = [ + 'client', + 'conditions', + 'items', + 'personalization', + 'review', + ]; const currentIndex = steps.indexOf(currentStep); - + if (currentIndex > 0) { setCurrentStep(steps[currentIndex - 1]); window.scrollTo({ top: 0, behavior: 'smooth' }); } }, [currentStep]); - - const goToStep = useCallback((step: QuoteBuilderStep) => { - const steps: QuoteBuilderStep[] = ['client', 'conditions', 'items', 'personalization', 'review']; - const targetIndex = steps.indexOf(step); - const currentIndex = steps.indexOf(currentStep); - - if (targetIndex === currentIndex) return; - // Se estiver tentando ir para uma etapa posterior, validar as anteriores - if (targetIndex > currentIndex) { - // Validar cada etapa entre a atual e a alvo (não inclusiva da alvo, pois a alvo é onde queremos chegar) - for (let i = currentIndex; i < targetIndex; i++) { - if (!validateStep(steps[i])) return; + const goToStep = useCallback( + (step: QuoteBuilderStep) => { + const steps: QuoteBuilderStep[] = [ + 'client', + 'conditions', + 'items', + 'personalization', + 'review', + ]; + const targetIndex = steps.indexOf(step); + const currentIndex = steps.indexOf(currentStep); + + if (targetIndex === currentIndex) return; + + // Se estiver tentando ir para uma etapa posterior, validar as anteriores + if (targetIndex > currentIndex) { + // Validar cada etapa entre a atual e a alvo (não inclusiva da alvo, pois a alvo é onde queremos chegar) + for (let i = currentIndex; i < targetIndex; i++) { + if (!validateStep(steps[i])) return; + } } - } - setCurrentStep(step); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }, [currentStep, validateStep]); + setCurrentStep(step); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [currentStep, validateStep], + ); // ── AutoSave ── const { clearAutoSave } = useAutoSaveQuote({ enabled: (!!clientId || items.length > 0) && !isEditMode, @@ -792,7 +848,16 @@ export function useQuoteBuilderState() { shippingCost, itemsCount: items.length, }), - [clientId, contactId, paymentMethod, paymentTerms, deliveryTime, shippingType, shippingCost, items], + [ + clientId, + contactId, + paymentMethod, + paymentTerms, + deliveryTime, + shippingType, + shippingCost, + items, + ], ); const isFormValid = validationErrors.length === 0; @@ -867,8 +932,7 @@ export function useQuoteBuilderState() { payment_terms: paymentTerms || undefined, delivery_time: deliveryTime || undefined, shipping_type: shippingType || undefined, - shipping_cost: - shippingType === 'fob_pre' ? (shippingCost || 0) : 0, + shipping_cost: shippingType === 'fob_pre' ? shippingCost || 0 : 0, }; let result; if (isEditMode && quoteId) { diff --git a/src/lib/external-db/product-types.ts b/src/lib/external-db/product-types.ts index 807cf3ffa..aa7817ac4 100644 --- a/src/lib/external-db/product-types.ts +++ b/src/lib/external-db/product-types.ts @@ -74,14 +74,32 @@ export interface PromobrindProduct { price_updated_at?: string | null; price_freshness_threshold_days?: number | null; kit_components?: Array<{ - id: string; component_name: string | null; component_code: string | null; - component_product_id: string | null; component_sku: string | null; - quantity: number | null; display_order: number | null; - is_optional: boolean | null; is_packaging: boolean | null; - is_replaceable: boolean | null; allows_personalization: boolean | null; - material: string | null; primary_image_url: string | null; - height_mm: number | null; width_mm: number | null; length_mm: number | null; - weight_g: number | null; notes: string | null; + id: string; + component_name: string | null; + component_code: string | null; + component_product_id: string | null; + component_sku: string | null; + quantity: number | null; + display_order: number | null; + is_optional: boolean | null; + is_packaging: boolean | null; + is_replaceable: boolean | null; + allows_personalization: boolean | null; + material: string | null; + primary_image_url: string | null; + height_mm: number | null; + width_mm: number | null; + length_mm: number | null; + weight_g: number | null; + notes: string | null; + // Campos provenientes do join `product_kit_components` × `products` + // (vide JSON_BUILD_OBJECT em supabase/migrations/20250103070000…). + // Podem vir ausentes em produtos mais antigos sem catálogo completo. + component_type_code?: string | null; + supplier_component_code?: string | null; + component_description?: string | null; + personalization_notes?: string | null; + color?: string | null; }> | null; // ------------------------------------------------------------------ @@ -208,5 +226,7 @@ export const PRODUCT_SELECT_FIELDS_DETAIL = // #2: also trigger fallback when orderBy hits a missing column export function shouldFallbackSelect(err: unknown) { const msg = err instanceof Error ? err.message : String(err); - return /(sale_price|base_price|image_url|supplier_name|category_name|product_videos|selected_images|gender|price_updated_at|price_freshness_threshold_days|does not exist|não existe|undefined column|column .+ does not exist|could not identify an ordering operator|order by)/i.test(msg); + return /(sale_price|base_price|image_url|supplier_name|category_name|product_videos|selected_images|gender|price_updated_at|price_freshness_threshold_days|does not exist|não existe|undefined column|column .+ does not exist|could not identify an ordering operator|order by)/i.test( + msg, + ); } diff --git a/src/lib/print-area-grouping.ts b/src/lib/print-area-grouping.ts index 897b3937c..f9074b762 100644 --- a/src/lib/print-area-grouping.ts +++ b/src/lib/print-area-grouping.ts @@ -9,7 +9,7 @@ * - Estatísticas e resumos * - Detecção de área máxima por grupo */ -import type { PrintAreaWithTechniques, GroupedPrintArea } from "@/types/gravacao"; +import type { PrintAreaWithTechniques, GroupedPrintArea } from '@/types/gravacao'; // ============================================ // AGRUPAMENTO PRINCIPAL @@ -19,35 +19,35 @@ import type { PrintAreaWithTechniques, GroupedPrintArea } from "@/types/gravacao * Agrupa áreas de impressão/personalização por componente → localização → técnicas. * Áreas sem componente são agrupadas sob "Produto" (default). */ -export function groupPrintAreasByComponent( - areas: PrintAreaWithTechniques[] -): GroupedPrintArea[] { +export function groupPrintAreasByComponent(areas: PrintAreaWithTechniques[]): GroupedPrintArea[] { if (!areas.length) return []; - const componentMap = new Map>(); + const componentMap = new Map< + string, + Map + >(); for (const area of areas) { - const compName = area.component_name || "Produto"; - const locName = area.location_name || area.area_name || "Padrão"; + const compName = area.component_name || 'Produto'; + const locName = area.location_name || area.area_name || 'Padrão'; - if (!componentMap.has(compName)) { - componentMap.set(compName, new Map()); + let locMap = componentMap.get(compName); + if (!locMap) { + locMap = new Map(); + componentMap.set(compName, locMap); } - const locMap = componentMap.get(compName)!; - if (!locMap.has(locName)) { - locMap.set(locName, []); + let techniques = locMap.get(locName); + if (!techniques) { + techniques = []; + locMap.set(locName, techniques); } - const techniques = locMap.get(locName)!; - for (const tech of area.techniques) { const code = tech.codigo; // Deduplicação: evita técnica duplicada na mesma localização+área - const isDuplicate = techniques.some( - (t) => t.techniqueCode === code && t.id === area.area_id - ); + const isDuplicate = techniques.some((t) => t.techniqueCode === code && t.id === area.area_id); if (isDuplicate) continue; techniques.push({ @@ -71,12 +71,12 @@ export function groupPrintAreasByComponent( const grouped: GroupedPrintArea[] = []; for (const [compName, locMap] of componentMap) { - const locations: GroupedPrintArea["locations"] = []; + const locations: GroupedPrintArea['locations'] = []; for (const [locName, techniques] of locMap) { locations.push({ locationName: locName, - locationCode: locName.toLowerCase().replace(/\s+/g, "-"), + locationCode: locName.toLowerCase().replace(/\s+/g, '-'), techniques, }); } @@ -92,15 +92,15 @@ export function groupPrintAreasByComponent( grouped.push({ componentName: compName, - componentCode: compName.toLowerCase().replace(/\s+/g, "-"), + componentCode: compName.toLowerCase().replace(/\s+/g, '-'), locations, }); } // Sort: "Produto" first, then alphabetical grouped.sort((a, b) => { - if (a.componentName === "Produto") return -1; - if (b.componentName === "Produto") return 1; + if (a.componentName === 'Produto') return -1; + if (b.componentName === 'Produto') return 1; return a.componentName.localeCompare(b.componentName); }); @@ -131,7 +131,7 @@ export function getUniqueTechniques(groups: GroupedPrintArea[]): string[] { */ export function filterGroupsByTechnique( groups: GroupedPrintArea[], - techniqueCode: string + techniqueCode: string, ): GroupedPrintArea[] { return groups .map((g) => ({ @@ -151,7 +151,7 @@ export function filterGroupsByTechnique( */ export function filterGroupsByComponent( groups: GroupedPrintArea[], - componentName: string + componentName: string, ): GroupedPrintArea[] { return groups.filter((g) => g.componentName === componentName); } @@ -294,7 +294,7 @@ export function summarizeGroups(groups: GroupedPrintArea[]): PrintAreaSummary { * Encontra a maior área disponível (em cm²) entre todos os grupos. */ export function findLargestArea( - groups: GroupedPrintArea[] + groups: GroupedPrintArea[], ): { componentName: string; locationName: string; areaCm2: number } | null { let largest: { componentName: string; locationName: string; areaCm2: number } | null = null; diff --git a/src/pages/products/FavoritesPage.tsx b/src/pages/products/FavoritesPage.tsx index e55380be0..95d13110e 100644 --- a/src/pages/products/FavoritesPage.tsx +++ b/src/pages/products/FavoritesPage.tsx @@ -1,58 +1,72 @@ -import { useEffect, useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { PageSEO } from "@/components/seo/PageSEO"; -import { useFavoritesStore, type FavoriteVariantInfo } from "@/stores/useFavoritesStore"; +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageSEO } from '@/components/seo/PageSEO'; +import { useFavoritesStore, type FavoriteVariantInfo } from '@/stores/useFavoritesStore'; import { + useEnrichedFavoriteItems, useFavoriteLists, + useFavoritesGlobalShortcuts, useFavoriteTrash, useLegacyFavoritesMigration, -} from "@/hooks/favorites"; -import { useEnrichedFavoriteItems, useFavoritesGlobalShortcuts } from "@/hooks/favorites"; -import { useProductsContext } from "@/contexts/ProductsContext"; -import { ProductCard } from "@/components/products/ProductCard"; -import { ProductListItem } from "@/components/products/ProductListItem"; -import { ProductTableView } from "@/components/products/ProductTableView"; -import { LayoutPopover } from "@/components/products/LayoutPopover"; -import { getDefaultColumns, type ColumnCount } from "@/components/products/ColumnSelector"; -import { getGridColsClass, getGridGapClass } from "@/components/replenishments/VirtualizedReplenishmentGrid"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +} from '@/hooks/favorites'; +import { useProductsContext } from '@/contexts/ProductsContext'; +import { ProductCard } from '@/components/products/ProductCard'; +import { ProductListItem } from '@/components/products/ProductListItem'; +import { ProductTableView } from '@/components/products/ProductTableView'; +import { LayoutPopover } from '@/components/products/LayoutPopover'; +import { getDefaultColumns, type ColumnCount } from '@/components/products/ColumnSelector'; import { - Heart, Trash2, Search, Package, Layers, TrendingDown, TrendingUp, - CheckSquare, X, FolderOpen, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import { motion, AnimatePresence } from "framer-motion"; -import { toast } from "sonner"; -import { DeleteConfirmDialog } from "@/components/ui/ConfirmDialog"; - -import { useCatalogSelection } from "@/components/catalog/useCatalogSelection"; -import { CatalogBulkModals } from "@/components/catalog/CatalogBulkModals"; -import { FavoriteListsSidebar } from "@/components/favorites/FavoriteListsSidebar"; -import { FavoritesTrashView } from "@/components/favorites/FavoritesTrashView"; -import { FavoritesViewHeader } from "@/components/favorites/FavoritesViewHeader"; -import { ItemNoteEditor } from "@/components/favorites/ItemNoteEditor"; -import { PriceDropBadge } from "@/components/favorites/PriceDropBadge"; -import { FavoritesEmptyStateSmart } from "@/components/favorites/FavoritesEmptyStateSmart"; -import { FavoritePresentationLauncher } from "@/components/favorites/FavoritePresentationLauncher"; -import { useUndoStack } from "@/hooks/common"; -import type { FavoritesSort } from "@/components/favorites/FavoritesSortBar"; - -type ViewMode = "grid" | "list" | "table"; -const VIEW_MODE_KEY = "favorites-view-mode"; -const GRID_COLS_KEY = "favorites-grid-cols"; -const SELECTED_LIST_KEY = "favorites-selected-list-id"; -const SORT_KEY = "favorites-sort"; -const PRICE_DROP_FILTER_KEY = "favorites-only-drops"; + getGridColsClass, + getGridGapClass, +} from '@/components/replenishments/VirtualizedReplenishmentGrid'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { + Heart, + Trash2, + Search, + Package, + Layers, + TrendingDown, + TrendingUp, + CheckSquare, + X, + FolderOpen, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { motion, AnimatePresence } from 'framer-motion'; +import { toast } from 'sonner'; +import { DeleteConfirmDialog } from '@/components/ui/ConfirmDialog'; + +import { useCatalogSelection } from '@/components/catalog/useCatalogSelection'; +import { CatalogBulkModals } from '@/components/catalog/CatalogBulkModals'; +import { FavoriteListsSidebar } from '@/components/favorites/FavoriteListsSidebar'; +import { FavoritesTrashView } from '@/components/favorites/FavoritesTrashView'; +import { FavoritesViewHeader } from '@/components/favorites/FavoritesViewHeader'; +import { ItemNoteEditor } from '@/components/favorites/ItemNoteEditor'; +import { PriceDropBadge } from '@/components/favorites/PriceDropBadge'; +import { FavoritesEmptyStateSmart } from '@/components/favorites/FavoritesEmptyStateSmart'; +import { FavoritePresentationLauncher } from '@/components/favorites/FavoritePresentationLauncher'; +import { useUndoStack } from '@/hooks/common'; +import type { FavoritesSort } from '@/components/favorites/FavoritesSortBar'; + +type ViewMode = 'grid' | 'list' | 'table'; +const VIEW_MODE_KEY = 'favorites-view-mode'; +const GRID_COLS_KEY = 'favorites-grid-cols'; +const SELECTED_LIST_KEY = 'favorites-selected-list-id'; +const SORT_KEY = 'favorites-sort'; +const PRICE_DROP_FILTER_KEY = 'favorites-only-drops'; function loadViewMode(): ViewMode { try { const v = localStorage.getItem(VIEW_MODE_KEY); - if (v === "grid" || v === "list" || v === "table") return v as ViewMode; - } catch { /* empty */ } - return "grid"; + if (v === 'grid' || v === 'list' || v === 'table') return v as ViewMode; + } catch { + /* empty */ + } + return 'grid'; } function loadGridColumns(): ColumnCount { @@ -62,17 +76,29 @@ function loadGridColumns(): ColumnCount { const n = Number(v) as ColumnCount; if ([3, 4, 5, 6, 8].includes(n)) return n as ColumnCount; } - } catch { /* empty */ } + } catch { + /* empty */ + } return getDefaultColumns(); } function loadSort(): FavoritesSort { try { const v = localStorage.getItem(SORT_KEY) as FavoritesSort | null; - const allowed: FavoritesSort[] = ["recent", "oldest", "price-asc", "price-desc", "name-asc", "name-desc", "category"]; + const allowed: FavoritesSort[] = [ + 'recent', + 'oldest', + 'price-asc', + 'price-desc', + 'name-asc', + 'name-desc', + 'category', + ]; if (v && allowed.includes(v)) return v; - } catch { /* empty */ } - return "recent"; + } catch { + /* empty */ + } + return 'recent'; } export default function FavoritesPage() { @@ -82,53 +108,85 @@ export default function FavoritesPage() { useUndoStack(); useLegacyFavoritesMigration(); - const { favorites, clearFavorites, favoriteCount, toggleFavorite, isFavorite } = useFavoritesStore(); + const { favorites, clearFavorites, favoriteCount, toggleFavorite, isFavorite } = + useFavoritesStore(); - const { - lists, - createList, - updateList, - deleteList, - generateShareToken, - revokeShareToken, - } = useFavoriteLists(); + const { lists, createList, updateList, deleteList, generateShareToken, revokeShareToken } = + useFavoriteLists(); const { items: trashItems } = useFavoriteTrash(); const [selectedListId, setSelectedListId] = useState(() => { - try { return localStorage.getItem(SELECTED_LIST_KEY); } catch { return null; } + try { + return localStorage.getItem(SELECTED_LIST_KEY); + } catch { + return null; + } }); const [showTrash, setShowTrash] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [presenting, setPresenting] = useState(false); - const [ariaAnnouncement, setAriaAnnouncement] = useState(""); + const [ariaAnnouncement, setAriaAnnouncement] = useState(''); useEffect(() => { try { if (selectedListId) localStorage.setItem(SELECTED_LIST_KEY, selectedListId); else localStorage.removeItem(SELECTED_LIST_KEY); - } catch { /* empty */ } + } catch { + /* empty */ + } }, [selectedListId]); const { enriched, rawItems, removeItem, updateItem } = useEnrichedFavoriteItems(selectedListId); const isRemoteListView = !!selectedListId && !showTrash; const { getProductsByIds, products: _cacheSignal } = useProductsContext(); - const [searchQuery, setSearchQuery] = useState(""); + const [searchQuery, setSearchQuery] = useState(''); const [viewMode, setViewMode] = useState(() => loadViewMode()); const [gridColumns, setGridColumns] = useState(() => loadGridColumns()); const [sort, setSort] = useState(() => loadSort()); const [selectionMode, setSelectionMode] = useState(false); const [onlyPriceDrops, setOnlyPriceDrops] = useState(() => { - try { return localStorage.getItem(PRICE_DROP_FILTER_KEY) === "1"; } catch { return false; } + try { + return localStorage.getItem(PRICE_DROP_FILTER_KEY) === '1'; + } catch { + return false; + } }); - useEffect(() => { try { localStorage.setItem(VIEW_MODE_KEY, viewMode); } catch { /* empty */ } }, [viewMode]); - useEffect(() => { try { localStorage.setItem(GRID_COLS_KEY, String(gridColumns)); } catch { /* empty */ } }, [gridColumns]); - useEffect(() => { try { localStorage.setItem(SORT_KEY, sort); } catch { /* empty */ } }, [sort]); - useEffect(() => { try { localStorage.setItem(PRICE_DROP_FILTER_KEY, onlyPriceDrops ? "1" : "0"); } catch { /* empty */ } }, [onlyPriceDrops]); + useEffect(() => { + try { + localStorage.setItem(VIEW_MODE_KEY, viewMode); + } catch { + /* empty */ + } + }, [viewMode]); + useEffect(() => { + try { + localStorage.setItem(GRID_COLS_KEY, String(gridColumns)); + } catch { + /* empty */ + } + }, [gridColumns]); + useEffect(() => { + try { + localStorage.setItem(SORT_KEY, sort); + } catch { + /* empty */ + } + }, [sort]); + useEffect(() => { + try { + localStorage.setItem(PRICE_DROP_FILTER_KEY, onlyPriceDrops ? '1' : '0'); + } catch { + /* empty */ + } + }, [onlyPriceDrops]); const enrichedMetaMap = useMemo(() => { - const m = new Map(); + const m = new Map< + string, + { priceDiffPct: number | null; priceAtSave: number | null; savedAt: string } + >(); if (isRemoteListView) { enriched.forEach((e) => { m.set(e.item.product_id, { @@ -148,7 +206,7 @@ export default function FavoritesPage() { const legacyFavoriteProducts = useMemo( () => getProductsByIds(favorites.map((f) => f.productId)), - [getProductsByIds, favorites, _cacheSignal] + [getProductsByIds, favorites, _cacheSignal], ); const variantMap = useMemo(() => { @@ -192,27 +250,46 @@ export default function FavoritesPage() { let list = productsWithVariant; if (searchQuery.trim()) { const q = searchQuery.toLowerCase(); - list = list.filter((p) => - p.name.toLowerCase().includes(q) || - p.sku?.toLowerCase().includes(q) || - p.brand?.toLowerCase().includes(q) + list = list.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.sku?.toLowerCase().includes(q) || + p.brand?.toLowerCase().includes(q), ); } if (onlyPriceDrops && isRemoteListView) { list = list.filter((p) => { const meta = enrichedMetaMap.get(p.id); - return meta?.priceDiffPct !== null && meta?.priceDiffPct !== undefined && meta.priceDiffPct < -2; + return ( + meta?.priceDiffPct !== null && meta?.priceDiffPct !== undefined && meta.priceDiffPct < -2 + ); }); } const sorted = [...list]; switch (sort) { - case "price-asc": sorted.sort((a, b) => (a.price ?? 0) - (b.price ?? 0)); break; - case "price-desc": sorted.sort((a, b) => (b.price ?? 0) - (a.price ?? 0)); break; - case "name-asc": sorted.sort((a, b) => a.name.localeCompare(b.name, "pt-BR")); break; - case "name-desc": sorted.sort((a, b) => b.name.localeCompare(a.name, "pt-BR")); break; - case "category": sorted.sort((a, b) => (a.category_name ?? "").localeCompare(b.category_name ?? "", "pt-BR")); break; - case "oldest": sorted.reverse(); break; - case "recent": default: break; + case 'price-asc': + sorted.sort((a, b) => (a.price ?? 0) - (b.price ?? 0)); + break; + case 'price-desc': + sorted.sort((a, b) => (b.price ?? 0) - (a.price ?? 0)); + break; + case 'name-asc': + sorted.sort((a, b) => a.name.localeCompare(b.name, 'pt-BR')); + break; + case 'name-desc': + sorted.sort((a, b) => b.name.localeCompare(a.name, 'pt-BR')); + break; + case 'category': + sorted.sort((a, b) => + (a.category_name ?? '').localeCompare(b.category_name ?? '', 'pt-BR'), + ); + break; + case 'oldest': + sorted.reverse(); + break; + case 'recent': + default: + break; } return sorted; }, [productsWithVariant, searchQuery, sort, onlyPriceDrops, isRemoteListView, enrichedMetaMap]); @@ -232,18 +309,22 @@ export default function FavoritesPage() { }; }, [productsWithVariant, legacyFavoriteProducts, isRemoteListView]); - const fmt = (v: number) => new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(v); + const fmt = (v: number) => + new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v); - const activeList = useMemo(() => lists.find((l) => l.id === selectedListId) ?? null, [lists, selectedListId]); + const activeList = useMemo( + () => lists.find((l) => l.id === selectedListId) ?? null, + [lists, selectedListId], + ); const headerTotalCount = isRemoteListView ? rawItems.length : favoriteCount; const handleClearAll = () => { if (isRemoteListView) { - toast.info("Use a lixeira para remover items individualmente"); + toast.info('Use a lixeira para remover items individualmente'); return; } clearFavorites(); - toast.success("Todos os favoritos foram removidos"); + toast.success('Todos os favoritos foram removidos'); }; const toggleSelectionMode = () => { @@ -263,7 +344,7 @@ export default function FavoritesPage() { } else { ids.forEach((id) => toggleFavorite(id)); } - toast.success(`${ids.length} ${ids.length === 1 ? "item removido" : "itens removidos"}`); + toast.success(`${ids.length} ${ids.length === 1 ? 'item removido' : 'itens removidos'}`); sel.clearSelection(); setSelectionMode(false); }; @@ -280,7 +361,9 @@ export default function FavoritesPage() { }; const handleToggleFavorite = (productId: string) => { - const product = (isRemoteListView ? productsWithVariant : legacyFavoriteProducts).find((p) => p.id === productId); + const product = (isRemoteListView ? productsWithVariant : legacyFavoriteProducts).find( + (p) => p.id === productId, + ); if (isRemoteListView) { const meta = noteMap.get(productId); if (meta) removeItem.mutate(meta.itemId); @@ -295,418 +378,496 @@ export default function FavoritesPage() { const meta = noteMap.get(productId); if (!meta) return; await updateItem.mutateAsync({ id: meta.itemId, note }); - toast.success("Nota salva"); + toast.success('Nota salva'); }; const sidebarNode = ( { setSelectedListId(id); setShowTrash(false); setSidebarOpen(false); }} - onCreateList={async (data) => { await createList.mutateAsync(data); }} - onUpdateList={async (id, patch) => { await updateList.mutateAsync({ id, ...patch }); }} + onSelectList={(id) => { + setSelectedListId(id); + setShowTrash(false); + setSidebarOpen(false); + }} + onCreateList={async (data) => { + await createList.mutateAsync(data); + }} + onUpdateList={async (id, patch) => { + await updateList.mutateAsync({ id, ...patch }); + }} onDeleteList={async (id) => { await deleteList.mutateAsync(id); if (selectedListId === id) setSelectedListId(null); }} - onShareList={async (id, days) => generateShareToken.mutateAsync({ listId: id, expiresInDays: days })} - onRevokeShare={async (id) => { await revokeShareToken.mutateAsync(id); }} + onShareList={async (id, days) => + generateShareToken.mutateAsync({ listId: id, expiresInDays: days }) + } + onRevokeShare={async (id) => { + await revokeShareToken.mutateAsync(id); + }} trashCount={trashItems.length} showTrash={showTrash} - onToggleTrash={(s) => { setShowTrash(s); if (s) setSidebarOpen(false); }} + onToggleTrash={(s) => { + setShowTrash(s); + if (s) setSidebarOpen(false); + }} /> ); return ( - <> - -
-
-
-
- -
-
-

- Meus Favoritos -

-

- {headerTotalCount}{" "} - {headerTotalCount === 1 ? "item" : "itens"} - {lists.length > 0 && ( - <> - {" • "} - {lists.length}{" "} - {lists.length === 1 ? "lista" : "listas"} - - )} -

-
+ <> + +
+
+
+
+
- -
- - - - - - {sidebarNode} - - - - {(headerTotalCount > 0 && !showTrash) && ( - <> - {!isRemoteListView && ( - - - Limpar Tudo - - } - title="Limpar todos os favoritos?" - description={`Esta ação irá remover todos os ${favoriteCount} produtos.`} - onConfirm={handleClearAll} - itemName="favoritos" - /> - )} - -
- -
- - )} +
+

+ Meus Favoritos +

+

+ {headerTotalCount}{' '} + {headerTotalCount === 1 ? 'item' : 'itens'} + {lists.length > 0 && ( + <> + {' • '} + {lists.length}{' '} + {lists.length === 1 ? 'lista' : 'listas'} + + )} +

-
-
- {sidebarNode} -
- -
- {showTrash ? ( - <> -
- -

Lixeira

-
- - - ) : ( - <> - 0 ? () => setPresenting(true) : undefined} +
+ + + + + + {sidebarNode} + + + + {headerTotalCount > 0 && !showTrash && ( + <> + {!isRemoteListView && ( + + + Limpar Tudo + + } + title="Limpar todos os favoritos?" + description={`Esta ação irá remover todos os ${favoriteCount} produtos.`} + onConfirm={handleClearAll} + itemName="favoritos" + /> + )} + +
+ +
+ + )} +
+
- {stats && ( -
-
-
- -
-
-

{stats.total}

-

Produtos

-
+
+
{sidebarNode}
+ +
+ {showTrash ? ( + <> +
+ +

Lixeira

+
+ + + ) : ( + <> + 0 ? () => setPresenting(true) : undefined} + /> + + {stats && ( +
+
+
+
-
-
- -
-
-

{stats.categories}

-

Categorias

-
+
+

+ {stats.total} +

+

Produtos

-
-
- -
-
-

{fmt(stats.minPrice)}

-

Menor preço

-
+
+
+
+
-
-
- -
-
-

{fmt(stats.maxPrice)}

-

Maior preço

-
+
+

+ {stats.categories} +

+

Categorias

- )} - - {productsWithVariant.length > 0 && ( -
- - setSearchQuery(e.target.value)} - className="pl-9" - /> +
+
+ +
+
+

+ {fmt(stats.minPrice)} +

+

Menor preço

+
- )} - - {selectionMode && productsWithVariant.length > 0 && ( -
-
- - - {selectedIds.size} {selectedIds.size === 1 ? "selecionado" : "selecionados"} - - de {filteredProducts.length} +
+
+
-
- - - - - Remover ({selectedIds.size}) - - } - title="Remover selecionados?" - description={`Esta ação irá remover ${selectedIds.size} ${selectedIds.size === 1 ? "item" : "itens"}.`} - onConfirm={handleRemoveSelected} - itemName="itens selecionados" - /> +
+

+ {fmt(stats.maxPrice)} +

+

Maior preço

- )} - - {filteredProducts.length > 0 ? ( - viewMode === "table" ? ( - navigate(`/produto/${productId}`)} - isFavorite={isFavorite} - onToggleFavorite={handleToggleFavorite} - selectionMode={selectionMode} - selectedIds={selectedIds} - onToggleSelect={sel.toggleSelect} +
+ )} + + {productsWithVariant.length > 0 && ( +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ )} + + {selectionMode && productsWithVariant.length > 0 && ( +
+
+ + + {selectedIds.size} {selectedIds.size === 1 ? 'selecionado' : 'selecionados'} + + de {filteredProducts.length} +
+
+ + + + + Remover ({selectedIds.size}) + + } + title="Remover selecionados?" + description={`Esta ação irá remover ${selectedIds.size} ${selectedIds.size === 1 ? 'item' : 'itens'}.`} + onConfirm={handleRemoveSelected} + itemName="itens selecionados" /> - ) : viewMode === "list" ? ( -
- {filteredProducts.map((product) => { - const isSelected = selectedIds.has(product.id); - return ( -
sel.toggleSelect(product.id) : undefined} - > -
- navigate(`/produto/${product.id}`)} - isFavorited={isFavorite(product.id)} - onToggleFavorite={handleToggleFavorite} - /> -
- {selectionMode && ( -
-
- {isSelected && } -
+
+
+ )} + + {filteredProducts.length > 0 ? ( + viewMode === 'table' ? ( + navigate(`/produto/${productId}`)} + isFavorite={isFavorite} + onToggleFavorite={handleToggleFavorite} + selectionMode={selectionMode} + selectedIds={selectedIds} + onToggleSelect={sel.toggleSelect} + /> + ) : viewMode === 'list' ? ( +
+ {filteredProducts.map((product) => { + const isSelected = selectedIds.has(product.id); + return ( +
sel.toggleSelect(product.id) : undefined} + > +
+ navigate(`/produto/${product.id}`)} + isFavorited={isFavorite(product.id)} + onToggleFavorite={handleToggleFavorite} + /> +
+ {selectionMode && ( +
+
+ {isSelected && ( + + )}
- )} +
+ )} +
+ ); + })} +
+ ) : ( +
+ {filteredProducts.map((product, index) => { + const variant = variantMap.get(product.id); + const isSelected = selectedIds.has(product.id); + const noteMeta = noteMap.get(product.id); + const priceMeta = enrichedMetaMap.get(product.id); + return ( +
sel.toggleSelect(product.id) : undefined} + > +
+ navigate(`/produto/${product.id}`)} + onFavorite={() => handleRemoveFavorite(product.id, product.name)} + />
- ); - })} -
- ) : ( -
- {filteredProducts.map((product, index) => { - const variant = variantMap.get(product.id); - const isSelected = selectedIds.has(product.id); - const noteMeta = noteMap.get(product.id); - const priceMeta = enrichedMetaMap.get(product.id); - return ( -
sel.toggleSelect(product.id) : undefined} - > -
- navigate(`/produto/${product.id}`)} - onFavorite={() => handleRemoveFavorite(product.id, product.name)} + {isRemoteListView && priceMeta && !selectionMode && ( +
+
- {isRemoteListView && priceMeta && !selectionMode && ( -
- -
- )} - {selectionMode && ( -
-
- {isSelected && } -
-
- )} - {!selectionMode && ( -
- - {isRemoteListView && noteMeta && ( - handleSaveNote(product.id, note)} - /> + )} + {selectionMode && ( +
+
- {variant.color_hex && ( - - )} - {variant.color_name} - + > + {isSelected && ( + )}
- )} -
- ); - })} -
- ) - ) : productsWithVariant.length > 0 && searchQuery ? ( -
- -

- Nenhum favorito encontrado -

-

- Nenhum produto corresponde a "{searchQuery}" -

+
+ )} + {!selectionMode && ( +
+ + {isRemoteListView && noteMeta && ( + handleSaveNote(product.id, note)} + /> + )} + {variant?.color_name && ( + + {variant.color_hex && ( + + )} + + {variant.color_name} + + + )} +
+ )} +
+ ); + })}
- ) : ( - { - if (isRemoteListView && activeList) { - toast.info("Abra o produto e use o coração para adicionar a esta lista"); - navigate(`/produto/${productId}`); - } else { - navigate(`/produto/${productId}`); - } - }} - /> - )} - - )} -
+ ) + ) : productsWithVariant.length > 0 && searchQuery ? ( +
+ +

+ Nenhum favorito encontrado +

+

+ Nenhum produto corresponde a "{searchQuery}" +

+
+ ) : ( + { + if (isRemoteListView && activeList) { + toast.info('Abra o produto e use o coração para adicionar a esta lista'); + navigate(`/produto/${productId}`); + } else { + navigate(`/produto/${productId}`); + } + }} + /> + )} + + )}
- - - -
- {ariaAnnouncement} -
- - {presenting && ( - setPresenting(false)} - /> - )} - +
+ + + +
+ {ariaAnnouncement} +
+ + {presenting && ( + setPresenting(false)} + /> + )} + ); } diff --git a/tests/lib/theme-presets.test.ts b/tests/lib/theme-presets.test.ts index 33d463a66..41c22499a 100644 --- a/tests/lib/theme-presets.test.ts +++ b/tests/lib/theme-presets.test.ts @@ -34,17 +34,20 @@ import { const STORAGE_KEY = 'gifts-store-theme-config'; -// HSL canônico do Zapp Web. Manter sincronizado quando refazer port. +// HSL canônico — os valores L abaixo foram REDUZIDOS em relação ao Zapp Web +// original para conformidade WCAG (contraste AA com texto branco). Veja +// comentários `// Reduzido de XX para YY para contraste WCAG` em +// src/lib/theme-presets.ts. Manter sincronizado se o catálogo de skins mudar. const ZAPP_GX_HSL: Record = { 'gx-classic': { h: 347, s: 96, l: 54, gh: 340 }, - 'gx-pink-addiction': { h: 330, s: 95, l: 60, gh: 340 }, + 'gx-pink-addiction': { h: 330, s: 95, l: 50, gh: 340 }, 'gx-purple-haze': { h: 265, s: 65, l: 50, gh: 275 }, - 'gx-rose-quartz': { h: 345, s: 75, l: 68, gh: 355 }, + 'gx-rose-quartz': { h: 345, s: 75, l: 54, gh: 355 }, 'gx-ultraviolet': { h: 271, s: 76, l: 53, gh: 280 }, - 'gx-hackerman': { h: 127, s: 65, l: 46, gh: 135 }, - 'gx-frutti-di-mare': { h: 182, s: 90, l: 42, gh: 190 }, + 'gx-hackerman': { h: 127, s: 65, l: 40, gh: 135 }, + 'gx-frutti-di-mare': { h: 182, s: 90, l: 35, gh: 190 }, 'gx-cyberpunk': { h: 55, s: 100, l: 51, gh: 180 }, - 'gx-razer': { h: 113, s: 70, l: 51, gh: 120 }, + 'gx-razer': { h: 113, s: 70, l: 35, gh: 120 }, }; const CLASSIC_IDS = [ @@ -188,7 +191,7 @@ describe('§3 Skins Opera GX (paridade Zapp Web)', () => { }); it('gx-hackerman é verde Matrix (h=127)', () => { - expect(findPreset('gx-hackerman').dark.primary).toBe('127 65% 46%'); + expect(findPreset('gx-hackerman').dark.primary).toBe('127 65% 40%'); }); it('gx-cyberpunk é amarelo neon (h=55)', () => { @@ -604,8 +607,8 @@ describe('§11 Fluxo: reload da página com skin GX salva (ThemeInitializer)', ( expect(document.documentElement.style.getPropertyValue('--font-sans')).toContain('Inter'); // Radius 10px (GX friendly) expect(document.documentElement.style.getPropertyValue('--radius')).toBe('0.625rem'); - // Primary do Hackerman (h=127) - expect(document.documentElement.style.getPropertyValue('--primary')).toBe('127 65% 46%'); + // Primary do Hackerman (h=127) — L=40% pós-ajuste WCAG. + expect(document.documentElement.style.getPropertyValue('--primary')).toBe('127 65% 40%'); }); }); diff --git a/tests/lib/theme-radius-smoke.test.ts b/tests/lib/theme-radius-smoke.test.ts index 49dd9de02..3a9cc1dee 100644 --- a/tests/lib/theme-radius-smoke.test.ts +++ b/tests/lib/theme-radius-smoke.test.ts @@ -97,7 +97,7 @@ describe('Smoke E2E — fluxo completo: corporate → GX → corporate', () => { applyRadius(cfg.radius); expect(radiusPx()).toBe(10); expect(document.documentElement.style.getPropertyValue('--font-sans')).toContain('Inter'); - expect(document.documentElement.style.getPropertyValue('--primary')).toBe('127 65% 46%'); + expect(document.documentElement.style.getPropertyValue('--primary')).toBe('127 65% 40%'); // 3. Volta para Corporate cfg = { presetId: 'corporate', radius: 14, mode: 'auto' }; diff --git a/tests/unit/syntax-integrity.test.tsx b/tests/unit/syntax-integrity.test.tsx index 4c6853c0c..b2c6e8e63 100644 --- a/tests/unit/syntax-integrity.test.tsx +++ b/tests/unit/syntax-integrity.test.tsx @@ -13,6 +13,25 @@ import { SellerCartProvider } from "@/contexts/SellerCartContext"; import { OrganizationProvider } from "@/contexts/OrganizationContext"; import { AriaLiveProvider } from "@/components/a11y"; +// Mock OrganizationContext para evitar fetchOrganizations real (que dispara +// queries internas que travam o jsdom em CI). Usa React.createElement em +// vez de JSX para evitar problemas de hoisting do vi.mock. +vi.mock('@/contexts/OrganizationContext', async () => { + const ReactMod = await import('react'); + return { + OrganizationProvider: ({ children }: { children: React.ReactNode }) => + ReactMod.createElement(ReactMod.Fragment, null, children), + useOrganization: () => ({ + organizations: [], + currentOrg: null, + currentRole: null, + isLoading: false, + switchOrganization: vi.fn(), + createOrganization: vi.fn(), + }), + }; +}); + // Mock das dependências que poderiam causar efeitos colaterais ou erros de contexto vi.mock("@/integrations/supabase/client", () => ({