diff --git a/STATUS.md b/STATUS.md index 4b1bd8095..c9fac4545 100644 --- a/STATUS.md +++ b/STATUS.md @@ -99,6 +99,8 @@ Refatoração dos 5 arquivos com mais erros no `.tsc-baseline.json` — **235 er **Total estimado**: ~5h de trabalho cuidadoso. > 💡 Sugestão: rodar essas etapas **uma por sessão dedicada**, sem misturar com novas features. +> +> 💡 Lição da Etapa 13: a causa real no compare folder era import errado entre dois tipos `Product` distintos (`@/types/product` DB-oriented vs `@/types/product-catalog` runtime). Antes de atacar um componente por suposto problema snake_case/camelCase, primeiro confirme se ele importa o tipo runtime correto. --- diff --git a/docs/redeploy/REDEPLOY-ETAPA-13-COMPARE-FOLDER.md b/docs/redeploy/REDEPLOY-ETAPA-13-COMPARE-FOLDER.md new file mode 100644 index 000000000..50dc5e666 --- /dev/null +++ b/docs/redeploy/REDEPLOY-ETAPA-13-COMPARE-FOLDER.md @@ -0,0 +1,166 @@ +# Etapa 13 — Refactor compare folder + descoberta dos dois tipos `Product` + +**Data**: 2026-05-23 +**Branch**: `refactor/tsc-baseline-etapa-13-compare-table-view` +**Escopo do plano**: Etapa 13 do plano 20 etapas (PR #124) — refatorar `CompareTableView.tsx` (26 erros TSC). +**Escopo executado**: 13 arquivos (compare folder inteira + página) — extensão justificada pela descoberta arquitetural abaixo. + +--- + +## TL;DR + +A "dívida" do `.tsc-baseline.json` no top-5 não é dívida de código — é **dois tipos `Product` distintos** convivendo no repo, com componentes importando do tipo errado. O fix é mecânico (trocar imports + remover escape hatches `Record`), não estrutural. + +Resultado: **−64 erros TSC** com 13 arquivos modificados, zero regressão, zero impacto runtime. + +--- + +## A descoberta + +O repo tem **dois arquivos de tipos `Product`**: + +| Arquivo | Propósito | Estilo | +|---|---|---| +| `src/types/product.ts` | DB-oriented (modelo do Postgres) | snake_case (`is_kit`, `min_quantity`, `stock_status`, `category_name`), maioria `null`-able, sem objetos aninhados | +| `src/types/product-catalog.ts` | Runtime/UI (modelo após `mapPromobrindToProduct`) | camelCase (`isKit`, `minQuantity`, `stockStatus`), objetos aninhados (`category: {id, name}`, `supplier: {id, name}`, `tags: {publicoAlvo, datasComemorativas, ...}`), não-nullable | + +O *runtime data* que flui pela aplicação é `product-catalog.Product` (origem: `useProducts()` que internamente chama `mapPromobrindToProduct(rawDB) -> product-catalog.Product`). `ProductsContext`, `useProducts` e `useSupplierComparison` já usam `product-catalog.Product`. + +Mas **toda a pasta `src/components/compare/`** (7 arquivos) + `src/pages/products/ComparePage.tsx` importavam `Product` de `src/types/product.ts` (DB type). Por estrutural typing, o app rodava normal — mas o TSC reclamava de tudo. + +### Sintoma típico + +```ts +// Antes (em CompareTableView.tsx): +import type { Product } from "@/types/product"; +// ... +p.isKit // ❌ TS2551: Property 'isKit' does not exist on type 'Product'. Did you mean 'is_kit'? +p.minQuantity // ❌ TS2551: Property 'minQuantity' does not exist... +p.category?.name // ❌ TS2339: Property 'category' does not exist on type 'Product'. +p.images[0] // ❌ TS18047: 'entry.product.images' is possibly 'null'. +``` + +```ts +// Depois: +import type { Product } from "@/types/product-catalog"; +// ... +p.isKit // ✅ ok (boolean) +p.minQuantity // ✅ ok (number) +p.category?.name // ✅ ok (string) +p.images[0] // ✅ ok (string[] non-nullable) +``` + +Não foi necessário renomear nenhum acesso de `camelCase` → `snake_case` (como a doc original sugeria). O código já estava correto para o runtime — só o import estava errado. + +### O escape hatch `Record` + +Vários componentes "fugiam" do problema declarando props como `Record[]`, o que aceita qualquer coisa em tempo de compilação mas zera o type-safety interno: + +```ts +// Antes (em StockRiskBadge.tsx): +interface Props { + product: Record; // ❌ aceita qualquer coisa +} +// Internamente acessa product.minQuantity, product.stockStatus +// → TS não acusa, mas se algum dia o caller passar algo diferente, bug em produção. +``` + +```ts +// Depois: +import type { Product } from "@/types/product-catalog"; +interface Props { + product: Product; // ✅ contratual +} +``` + +5 componentes do compare folder usavam esse escape: `StockRiskBadge`, `OtherSuppliersRow`, `ComparisonScoreCard`, `ExportComparisonButton`, `SimilarProductsRail`. Todos eliminados nesta PR. + +### Campos que não existiam em nenhum tipo + +Dois campos eram acessados sem existir em nenhum dos dois `Product`: + +- `p.category?.icon` — não existe em `product-catalog.Product` (`category: {id, name}` apenas). +- `p.supplier?.verified` — idem (`supplier: {id, name}`). + +Em runtime, ambos retornavam `undefined` (renderizavam vazio). UI behavior preservada removendo-os do JSX. + +--- + +## Mudanças por arquivo + +| Arquivo | Tipo de mudança | TSC erros (antes → depois) | +|---|---|---:| +| `src/components/compare/CompareTableView.tsx` | import switch + drop 2 campos JSX inexistentes + cleanup import órfão `ShieldCheck` | 26 → 0 | +| `src/components/compare/StockRiskBadge.tsx` | `Record` → `Product` | 0 → 0 (preserva) | +| `src/components/compare/OtherSuppliersRow.tsx` | `Record` → `Product`, remove `as any` | 0 → 0 (preserva) | +| `src/components/compare/ComparisonScoreCard.tsx` | `Record` → `Product` | 2 → 0 | +| `src/components/compare/ExportComparisonButton.tsx` | `Record` → `Product` | 2 → 0 | +| `src/components/compare/SimilarProductsRail.tsx` | `Record` → `Product` | 5 → 4¹ | +| `src/components/compare/ComparisonPresentationLauncher.tsx` | import switch | 9 → 5² | +| `src/components/compare/ComparisonMobileView.tsx` | import switch | 5 → 0 | +| `src/components/compare/ComparisonDuelView.tsx` | import switch | 8 → 3³ | +| `src/components/compare/ComparisonRadarChart.tsx` | import switch | 2 → 0 | +| `src/components/compare/AIComparisonAdvisor.tsx` | import switch | 5 → 1⁴ | +| `src/components/compare/FloatingCompareBar.tsx` | import switch | 3 → 0 | +| `src/pages/products/ComparePage.tsx` | import switch | 10 → 0 | +| **Total** | **13 arquivos** | **77 → 13 (−64)** | + +¹ SimilarProductsRail: 4 erros residuais são pré-existentes — inferência do TanStack Query em `useProducts()` retorna `never[] | NoInfer_2`. Solução seria anotar explicitamente o destructure (`const { data: pool = [] }: { data?: Product[] }`). Fora de escopo de Etapa 13. + +² PresentationLauncher: 5 residuais — 1× `ProductScore.items` que não existe (bug separado), 4× implicit any em callback `.reduce()`. Bugs reais não relacionados a tipos `Product`. + +³ DuelView: 3 residuais — `p.leadTimeDays` não existe em nenhum dos dois tipos `Product`. Bug separado: precisa usar `leadTimeProxy(stockStatus)` como `CompareTableView` faz, ou estender o tipo. + +⁴ AIAdvisor: 1 residual — `'message' does not exist on type '{}'` em resposta de query mal tipada. Bug separado. + +--- + +## Validação + +```bash +# TSC gate +$ npm run typecheck +TS baseline gate — atual: 1189 erros · baseline: 1189 erros +✅ Nenhuma regressão de TypeScript detectada. + +# ESLint gate +$ npm run lint:baseline +# (1 drift pré-existente em src/pages/auth/AuthBranding.visual.test.tsx — não relacionado a esta PR) + +# Build end-to-end +$ npm run build +✓ built in 1m 36s +``` + +Baseline `.tsc-baseline.json` regenerado: **1333 → 1189 erros** (320 → 289 arquivos). + +--- + +## Impacto no plano de 20 etapas + +A Etapa 13 do plano original (PR #124) está formalmente fechada com mais escopo do que estimado (13 arquivos vs 1). Implicações para etapas 10-12 (`AddressTab.tsx`, `BasicDataTab.tsx`, `AdminProductFormPage.tsx`): + +- **Verificar primeiro** se os erros TSC são do mesmo padrão (import de `@/types/product` quando o componente espera o runtime `@/types/product-catalog`, ou vice-versa). +- Se for o mesmo padrão, a correção é mecânica (~5 min/arquivo). +- Se NÃO for o mesmo padrão, é refactor real — aí sim os ~3-4h/etapa estimados se justificam. + +A **Etapa 9** (`price-response.adapter.ts`) é arquiteturalmente diferente — é um adapter que mistura formatos, não um componente UI. Trate separadamente. + +--- + +## Próximos passos sugeridos (fora desta PR) + +1. **Etapa 13.5 (sugerida)**: Unificar os dois tipos `Product`. Hoje a coexistência é uma armadilha que tropeça em todo refactor. Possível abordagem: deletar `src/types/product.ts`, redirecionar os 30+ consumers para `product-catalog`, gerar `src/types/product-db.ts` a partir do Supabase typegen para uso restrito ao mapper. +2. **Limpar os 4 residuais do TanStack Query** em `SimilarProductsRail.tsx` (e padrão semelhante em outros hooks): tipar destructures de `useQuery`. +3. **Estender `Product` com `category.icon` + `supplier.verified`** (se UI quiser esses campos no futuro) ou aceitar formalmente que não existem. + +--- + +## Arquivos com escape hatch `Record` removidos + +Padrão que continua válido para futura caça: ainda existem outros componentes no repo com props tipadas como `Record[]` — quase sempre são candidatos a substituição por um tipo real. + +```bash +$ grep -rln "Record\[\]" src/ | grep -v test +# (lista para futura limpeza arquitetural) +``` diff --git a/src/components/compare/AIComparisonAdvisor.tsx b/src/components/compare/AIComparisonAdvisor.tsx index 01456d9bf..7e5703cd2 100644 --- a/src/components/compare/AIComparisonAdvisor.tsx +++ b/src/components/compare/AIComparisonAdvisor.tsx @@ -2,13 +2,13 @@ * AIComparisonAdvisor — Botão que chama edge function `comparison-ai-advisor` (Lovable AI). * Cache: sessionStorage por 30 min para combinação de IDs. */ -import { useState } from "react"; -import type { Product } from "@/types/product-catalog"; -import { Brain, Sparkles, Loader2, AlertCircle } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { supabase } from "@/integrations/supabase/client"; -import { toast } from "sonner"; +import { useState } from 'react'; +import type { Product } from '@/types/product-catalog'; +import { Brain, Sparkles, Loader2, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { supabase } from '@/integrations/supabase/client'; +import { toast } from 'sonner'; interface AIComparisonAdvisorProps { products: Product[]; @@ -23,7 +23,13 @@ interface AdvisorResult { const CACHE_TTL_MS = 30 * 60 * 1000; function cacheKey(products: Product[]): string { - return "cmp-ai-" + products.map(p => p.id).sort().join("|"); + return ( + 'cmp-ai-' + + products + .map((p) => p.id) + .sort() + .join('|') + ); } function readCache(key: string): AdvisorResult | null { @@ -33,30 +39,37 @@ function readCache(key: string): AdvisorResult | null { const parsed = JSON.parse(raw); if (Date.now() - parsed.t > CACHE_TTL_MS) return null; return parsed.data; - } catch { return null; } + } catch { + return null; + } } function writeCache(key: string, data: AdvisorResult) { try { sessionStorage.setItem(key, JSON.stringify({ t: Date.now(), data })); - } catch { /* ignore */ } + } catch { + /* ignore */ + } } export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) { const [loading, setLoading] = useState(false); const [result, setResult] = useState(() => - products.length >= 2 ? readCache(cacheKey(products)) : null + products.length >= 2 ? readCache(cacheKey(products)) : null, ); const fetchAdvice = async () => { if (products.length < 2) return; const key = cacheKey(products); const cached = readCache(key); - if (cached) { setResult(cached); return; } + if (cached) { + setResult(cached); + return; + } setLoading(true); try { - const slim = products.map(p => ({ + const slim = products.map((p) => ({ id: p.id, name: p.name, price: p.price, @@ -68,7 +81,7 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) { supplier: p.supplier?.name, })); - const { data, error } = await supabase.functions.invoke("comparison-ai-advisor", { + const { data, error } = await supabase.functions.invoke('comparison-ai-advisor', { body: { products: slim }, }); @@ -83,13 +96,13 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) { writeCache(key, advice); setResult(advice); } catch (e: unknown) { - const msg = e?.message ?? "Falha ao consultar IA"; - if (msg.includes("429") || msg.toLowerCase().includes("rate")) { - toast.error("Muitas requisições. Tente novamente em 1 minuto."); - } else if (msg.includes("402")) { - toast.error("Créditos de IA esgotados. Contate o administrador."); + const msg = e instanceof Error ? e.message : 'Falha ao consultar IA'; + if (msg.includes('429') || msg.toLowerCase().includes('rate')) { + toast.error('Muitas requisições. Tente novamente em 1 minuto.'); + } else if (msg.includes('402')) { + toast.error('Créditos de IA esgotados. Contate o administrador.'); } else { - toast.error("Não foi possível obter recomendação da IA."); + toast.error('Não foi possível obter recomendação da IA.'); } } finally { setLoading(false); @@ -100,35 +113,39 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) { return (
-
+
-

Conselheiro IA

- +

Conselheiro IA

+ Lovable AI
-

- Análise contextual da sua comparação -

+

Análise contextual da sua comparação

@@ -139,14 +156,14 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) {
    {result.bullets.map((b, i) => (
  • - + {b}
  • ))}
)} {result.bestFor && Object.keys(result.bestFor).length > 0 && ( -
+
{result.bestFor.highVolume && ( )} @@ -159,8 +176,8 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) {
)} {result.rationale && ( -

- +

+ {result.rationale}

)} @@ -173,10 +190,10 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) { function BestForCard({ label, value }: { label: string; value: string }) { return (
-

+

{label}

-

{value}

+

{value}

); } diff --git a/src/components/compare/CompareTableView.tsx b/src/components/compare/CompareTableView.tsx index 9dda877f4..baef421e2 100644 --- a/src/components/compare/CompareTableView.tsx +++ b/src/components/compare/CompareTableView.tsx @@ -3,23 +3,29 @@ * Sticky thumbnails ao rolar, hover swatch troca foto, AnimatePresence em colunas, * sparkline 30d, badge risco estoque, linha "outros fornecedores". */ -import { useEffect, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'; import { - Table, TableBody, TableCell, TableHead, TableHeader, TableRow, -} from "@/components/ui/table"; -import { X, Check, Minus, Crown, AlertTriangle } from "lucide-react"; -import { motion, AnimatePresence } from "framer-motion"; -import { cn } from "@/lib/utils"; -import { useComparisonHighlight, highlightClasses } from "./ComparisonHighlights"; -import { PriceSparkline } from "./PriceSparkline"; -import { StockRiskBadge } from "./StockRiskBadge"; -import { OtherSuppliersRow } from "./OtherSuppliersRow"; -import type { CompareVariantInfo } from "@/stores/useComparisonStore"; -import type { Product } from "@/types/product-catalog"; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { X, Check, Minus, Crown, AlertTriangle } from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { useComparisonHighlight, highlightClasses } from './ComparisonHighlights'; +import { PriceSparkline } from './PriceSparkline'; +import { StockRiskBadge } from './StockRiskBadge'; +import { OtherSuppliersRow } from './OtherSuppliersRow'; +import type { CompareVariantInfo } from '@/stores/useComparisonStore'; +// Runtime/UI Product (from useProducts) — distinct from src/types/product.ts (DB-oriented). +import type { Product } from '@/types/product-catalog'; export interface CompareEntry { product: Product; @@ -38,26 +44,34 @@ interface CompareTableViewProps { function leadTimeProxy(status: string | undefined): number { switch (status) { - case "in-stock": return 1; - case "low-stock": return 2; - case "out-of-stock": return 4; - default: return 2; + case 'in-stock': + return 1; + case 'low-stock': + return 2; + case 'out-of-stock': + return 4; + default: + return 2; } } function leadTimeLabel(status: string | undefined): string { switch (status) { - case "in-stock": return "1-3 dias"; - case "low-stock": return "5-10 dias"; - case "out-of-stock": return "Sob consulta"; - default: return "—"; + case 'in-stock': + return '1-3 dias'; + case 'low-stock': + return '5-10 dias'; + case 'out-of-stock': + return 'Sob consulta'; + default: + return '—'; } } function allEqual(arr: T[]): boolean { if (arr.length < 2) return true; const first = JSON.stringify(arr[0]); - return arr.every(v => JSON.stringify(v) === first); + return arr.every((v) => JSON.stringify(v) === first); } export function CompareTableView({ @@ -77,25 +91,32 @@ export function CompareTableView({ useEffect(() => { const el = headerSentinelRef.current; if (!el) return; - const observer = new IntersectionObserver( - ([entry]) => setHeaderStuck(!entry.isIntersecting), - { threshold: 0, rootMargin: "0px" } - ); + const observer = new IntersectionObserver(([entry]) => setHeaderStuck(!entry.isIntersecting), { + threshold: 0, + rootMargin: '0px', + }); observer.observe(el); return () => observer.disconnect(); }, []); const eq = { - sku: allEqual(products.map(p => p.sku)), - category: allEqual(products.map(p => p.category?.name)), - supplier: allEqual(products.map(p => p.supplier?.name)), - isKit: allEqual(products.map(p => p.isKit)), - materials: allEqual(products.map(p => (p.materials ?? []).slice().sort().join("|"))), - publico: allEqual(products.map(p => (p.tags?.publicoAlvo ?? []).slice().sort().join("|"))), - datas: allEqual(products.map(p => (p.tags?.datasComemorativas ?? []).slice().sort().join("|"))), - description: allEqual(products.map(p => p.description ?? "")), - weight: allEqual(products.map(p => p.dimensions?.weight_g ?? null)), - dims: allEqual(products.map(p => `${p.dimensions?.height_cm ?? ""}x${p.dimensions?.width_cm ?? ""}x${p.dimensions?.length_cm ?? ""}`)), + sku: allEqual(products.map((p) => p.sku)), + category: allEqual(products.map((p) => p.category?.name)), + supplier: allEqual(products.map((p) => p.supplier?.name)), + isKit: allEqual(products.map((p) => p.isKit)), + materials: allEqual(products.map((p) => (p.materials ?? []).slice().sort().join('|'))), + publico: allEqual(products.map((p) => (p.tags?.publicoAlvo ?? []).slice().sort().join('|'))), + datas: allEqual( + products.map((p) => (p.tags?.datasComemorativas ?? []).slice().sort().join('|')), + ), + description: allEqual(products.map((p) => p.description ?? '')), + weight: allEqual(products.map((p) => p.dimensions?.weight_g ?? null)), + dims: allEqual( + products.map( + (p) => + `${p.dimensions?.height_cm ?? ''}x${p.dimensions?.width_cm ?? ''}x${p.dimensions?.length_cm ?? ''}`, + ), + ), }; const showRow = (key: keyof typeof eq) => !differencesOnly || !eq[key]; @@ -112,22 +133,26 @@ export function CompareTableView({ initial={{ y: -40, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -40, opacity: 0 }} - className="sticky top-0 z-30 bg-background/95 backdrop-blur-md border-b border-border py-2 px-2 shadow-sm" + className="sticky top-0 z-30 border-b border-border bg-background/95 px-2 py-2 shadow-sm backdrop-blur-md" >
{entries.map((entry) => (
{entry.product.name} - {entry.product.name} - {formatCurrency(entry.product.price)} + + {entry.product.name} + + + {formatCurrency(entry.product.price)} +
))}
@@ -140,7 +165,7 @@ export function CompareTableView({ - Atributo + Atributo {entries.map((entry) => ( -
-
{entry.product.name} navigate(`/produto/${entry.product.id}`)} loading="lazy" /> - {entry.product.name} + + {entry.product.name} + {entry.variant?.color_name && ( - - {entry.variant.color_hex && } + + {entry.variant.color_hex && ( + + )} {entry.variant.color_name} )} {/* Hover swatches → swap header image */} {(entry.product.colors?.length ?? 0) > 1 && ( -
- {(entry.product.colors ?? []).slice(0, 6).map((c: { name: string; hex?: string }, i: number) => ( -
)} @@ -200,13 +246,21 @@ export function CompareTableView({ - p.price} renderFn={(v) => formatCurrency(v)} mode="lower-is-better" /> + p.price} + renderFn={(v) => formatCurrency(v)} + mode="lower-is-better" + /> {/* Price trend row (sparkline 30d) */} - +
Tendência (30d)
-
SPARKLINE
+
+ SPARKLINE +
{products.map((p, idx) => ( @@ -215,68 +269,203 @@ export function CompareTableView({ ))}
- p.minQuantity ?? 0} renderFn={(v) => `${v} un.`} mode="lower-is-better" /> - Number(p.price ?? 0) * Number(p.minQuantity ?? 1)} renderFn={(v) => formatCurrency(v)} mode="lower-is-better" subtitle="TCO" /> - p.stock ?? 0} renderFn={(v) => `${v.toLocaleString("pt-BR")} un.`} mode="higher-is-better" /> - leadTimeProxy(p.stockStatus ?? undefined)} renderFn={(v) => leadTimeLabel(v === 1 ? "in-stock" : v === 2 ? "low-stock" : "out-of-stock")} mode="lower-is-better" /> - (p.colors?.length ?? 0)} renderFn={(v) => `${v} cores`} mode="higher-is-better" /> + p.minQuantity} + renderFn={(v) => `${v} un.`} + mode="lower-is-better" + /> + Number(p.price ?? 0) * Number(p.minQuantity ?? 1)} + renderFn={(v) => formatCurrency(v)} + mode="lower-is-better" + subtitle="TCO" + /> + p.stock ?? 0} + renderFn={(v) => `${v.toLocaleString('pt-BR')} un.`} + mode="higher-is-better" + /> + leadTimeProxy(p.stockStatus)} + renderFn={(v) => + leadTimeLabel(v === 1 ? 'in-stock' : v === 2 ? 'low-stock' : 'out-of-stock') + } + mode="lower-is-better" + /> + p.colors?.length ?? 0} + renderFn={(v) => `${v} cores`} + mode="higher-is-better" + /> - {showRow("sku") && {p.sku}} />} - {showRow("category") && {p.category?.name}} />} - {showRow("supplier") && ( - ( -
- {p.supplier?.name} -
- )} /> + {showRow('sku') && ( + {p.sku}} + /> )} - { - const s = getStockStatusLabel(p.stockStatus ?? ""); - return ({s.label}); - }} /> - {showRow("isKit") && p.isKit ? : } />} - ( -
- {(p.colors ?? []).slice(0, 6).map((c: { name: string; hex?: string }, i: number) =>
)} - {(p.colors?.length ?? 0) > 6 && +{(p.colors?.length ?? 0) - 6}} -
- )} /> - {showRow("materials") && ( - ( -
- {(p.materials ?? []).map((m: string) => {m})} -
- )} /> + {showRow('category') && ( + {p.category?.name}} + /> + )} + {showRow('supplier') && ( + ( +
+ {p.supplier?.name} +
+ )} + /> )} - {showRow("weight") && p.dimensions?.weight_g ? {p.dimensions.weight_g} g : } />} - {showRow("dims") && ( - { - const d = p.dimensions; - if (!d) return ; - const parts = [d.height_cm, d.width_cm, d.length_cm].filter(Boolean); - return parts.length ? {parts.join(" × ")} cm : ; - }} /> + { + const s = getStockStatusLabel(p.stockStatus); + return {s.label}; + }} + /> + {showRow('isKit') && ( + + p.isKit ? ( + + ) : ( + + ) + } + /> )} - {showRow("publico") && ( - ( + (
- {(p.tags?.publicoAlvo ?? []).slice(0, 3).map((t: string) => {t})} + {p.colors?.slice(0, 6).map((c: { name: string; hex?: string }, i: number) => ( +
+ ))} + {(p.colors?.length ?? 0) > 6 && ( + +{p.colors.length - 6} + )}
- )} /> + )} + /> + {showRow('materials') && ( + ( +
+ {(p.materials ?? []).map((m: string) => ( + + {m} + + ))} +
+ )} + /> + )} + {showRow('weight') && ( + + p.dimensions?.weight_g ? ( + {p.dimensions.weight_g} g + ) : ( + + ) + } + /> + )} + {showRow('dims') && ( + { + const d = p.dimensions; + if (!d) return ; + const parts = [d.height_cm, d.width_cm, d.length_cm].filter(Boolean); + return parts.length ? ( + {parts.join(' × ')} cm + ) : ( + + ); + }} + /> + )} + {showRow('publico') && ( + ( +
+ {(p.tags?.publicoAlvo ?? []).slice(0, 3).map((t: string) => ( + + {t} + + ))} +
+ )} + /> + )} + {showRow('datas') && ( + + (p.tags?.datasComemorativas ?? []).length > 0 ? ( +
+ {p.tags.datasComemorativas.slice(0, 2).map((d: string) => ( + + {d} + + ))} +
+ ) : ( + + ) + } + /> )} - {showRow("datas") && ( - { - const datas = p.tags?.datasComemorativas ?? []; - return datas.length > 0 - ?
{datas.slice(0, 2).map((d: string) => {d})}
- : ; - }} /> + {showRow('description') && ( + ( +

{p.description}

+ )} + /> )} - {showRow("description") &&

{p.description}

} />} {/* Other suppliers — expandable */} - Alternativas + + Alternativas + {products.map((p, idx) => ( @@ -284,7 +473,15 @@ export function CompareTableView({ ))} - } /> + ( + + )} + />
@@ -294,46 +491,75 @@ export function CompareTableView({ ); } -function SimpleRow({ label, products, render }: { label: string; products: Product[]; render: (p: Product) => React.ReactNode }) { +function SimpleRow({ + label, + products, + render, +}: { + label: string; + products: Product[]; + render: (p: Product) => React.ReactNode; +}) { return ( - {label} - {products.map((p, idx) => {render(p)})} + {label} + {products.map((p, idx) => ( + + {render(p)} + + ))} ); } function HighlightedNumberRow({ - label, products, valueFn, renderFn, mode, subtitle, + label, + products, + valueFn, + renderFn, + mode, + subtitle, }: { label: string; products: Product[]; valueFn: (p: Product) => number; renderFn: (v: number) => string; - mode: "lower-is-better" | "higher-is-better"; + mode: 'lower-is-better' | 'higher-is-better'; subtitle?: string; }) { const values = products.map(valueFn); const highlights = useComparisonHighlight(values, mode); return ( - +
{label}
- {subtitle &&
{subtitle}
} + {subtitle && ( +
+ {subtitle} +
+ )}
{products.map((_, idx) => ( - +
- {highlights[idx] === "best" && } - + {highlights[idx] === 'best' && } + {renderFn(values[idx])} - {highlights[idx] === "worst" && } + {highlights[idx] === 'worst' && }
))} diff --git a/src/components/compare/ComparisonMobileView.tsx b/src/components/compare/ComparisonMobileView.tsx index 09414f331..348d7cfcd 100644 --- a/src/components/compare/ComparisonMobileView.tsx +++ b/src/components/compare/ComparisonMobileView.tsx @@ -2,12 +2,12 @@ * ComparisonMobileView — Carousel vertical de atributos para mobile (<768px). * Cada linha = atributo, produtos viram chips horizontais swipeable. */ -import { Badge } from "@/components/ui/badge"; -import type { Product, ProductColor } from "@/types/product-catalog"; -import { Button } from "@/components/ui/button"; -import { X, Crown } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useComparisonScore } from "@/hooks/comparison"; +import { Badge } from '@/components/ui/badge'; +import type { Product, ProductColor } from '@/types/product-catalog'; +import { Button } from '@/components/ui/button'; +import { X, Crown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useComparisonScore } from '@/hooks/comparison'; interface Props { products: Product[]; @@ -17,84 +17,103 @@ interface Props { } const ROWS = [ - { key: "image", label: "Foto" }, - { key: "name", label: "Produto" }, - { key: "price", label: "Preço" }, - { key: "minQty", label: "Qtd. mínima" }, - { key: "stock", label: "Estoque" }, - { key: "colors", label: "Cores" }, - { key: "supplier", label: "Fornecedor" }, + { key: 'image', label: 'Foto' }, + { key: 'name', label: 'Produto' }, + { key: 'price', label: 'Preço' }, + { key: 'minQty', label: 'Qtd. mínima' }, + { key: 'stock', label: 'Estoque' }, + { key: 'colors', label: 'Cores' }, + { key: 'supplier', label: 'Fornecedor' }, ] as const; -export function ComparisonMobileView({ products, formatCurrency, onRemove, onProductClick }: Props) { +export function ComparisonMobileView({ + products, + formatCurrency, + onRemove, + onProductClick, +}: Props) { const scoreItems = useComparisonScore(products); - const winnerIdx = scoreItems.length > 0 - ? scoreItems.reduce((best, cur, idx, arr) => cur.total > arr[best].total ? idx : best, 0) - : -1; + const winnerIdx = + scoreItems.length > 0 + ? scoreItems.reduce((best, cur, idx, arr) => (cur.total > arr[best].total ? idx : best), 0) + : -1; - const renderCell = (rowKey: typeof ROWS[number]["key"], p: Product, idx: number) => { + const renderCell = (rowKey: (typeof ROWS)[number]['key'], p: Product, idx: number) => { switch (rowKey) { - case "image": + case 'image': return ( -
- {p.name} +
+ {p.name} {winnerIdx === idx && ( - + )}
); - case "name": + case 'name': return ( ); - case "price": + case 'price': return {formatCurrency(p.price)}; - case "minQty": + case 'minQty': return {p.minQuantity ?? 0} un.; - case "stock": + case 'stock': return {p.stock ?? 0}; - case "colors": + case 'colors': return ( -
+
{(p.colors ?? []).slice(0, 4).map((c: ProductColor, i: number) => ( -
+
))} {p.colors?.length > 4 && +{p.colors.length - 4}}
); - case "supplier": - return {p.supplier?.name ?? "—"}; + case 'supplier': + return ( + + {p.supplier?.name ?? '—'} + + ); } }; return ( -
+
{ROWS.map((row) => ( -
-
+
+
{row.label}
-
+
{products.map((p, idx) => (
{renderCell(row.key, p, idx)} @@ -103,7 +122,12 @@ export function ComparisonMobileView({ products, formatCurrency, onRemove, onPro
))} -
diff --git a/src/components/compare/ComparisonPresentationLauncher.tsx b/src/components/compare/ComparisonPresentationLauncher.tsx index 45ae5ec57..222ed12f0 100644 --- a/src/components/compare/ComparisonPresentationLauncher.tsx +++ b/src/components/compare/ComparisonPresentationLauncher.tsx @@ -2,14 +2,14 @@ * ComparisonPresentationLauncher — Slide deck fullscreen 1 produto/slide + slide final tabela. * Atalhos: ← → navega, Esc fecha, F fullscreen do browser. */ -import { useState, useEffect, useCallback } from "react"; -import type { Product } from "@/types/product-catalog"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { ChevronLeft, ChevronRight, X, Maximize, Crown, Presentation } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useComparisonScore, type ProductScore } from "@/hooks/comparison"; +import { useState, useEffect, useCallback } from 'react'; +import type { Product } from '@/types/product-catalog'; +import { Dialog, DialogContent } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ChevronLeft, ChevronRight, X, Maximize, Crown, Presentation } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useComparisonScore } from '@/hooks/comparison'; interface Props { products: Product[]; @@ -21,31 +21,41 @@ export function ComparisonPresentationLauncher({ products, formatCurrency, trigg const [open, setOpen] = useState(false); const [slide, setSlide] = useState(0); const totalSlides = products.length + 1; // +1 para slide final tabela - const { items: scoreItems = [] } = useComparisonScore(products) || { items: [] }; - const winnerIdx = (scoreItems && scoreItems.length > 0) - ? scoreItems.reduce((best: number, cur: ProductScore, idx: number, arr: ProductScore[]) => cur.total > arr[best].total ? idx : best, 0) - : -1; + const scoreItems = useComparisonScore(products); + const winnerIdx = + scoreItems.length > 0 + ? scoreItems.reduce((best, cur, idx, arr) => (cur.total > arr[best].total ? idx : best), 0) + : -1; - const next = useCallback(() => setSlide(s => Math.min(s + 1, totalSlides - 1)), [totalSlides]); - const prev = useCallback(() => setSlide(s => Math.max(s - 1, 0)), []); + const next = useCallback(() => setSlide((s) => Math.min(s + 1, totalSlides - 1)), [totalSlides]); + const prev = useCallback(() => setSlide((s) => Math.max(s - 1, 0)), []); useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { - if (e.key === "ArrowRight") { e.preventDefault(); next(); } - else if (e.key === "ArrowLeft") { e.preventDefault(); prev(); } - else if (e.key === "Escape") { setOpen(false); } - else if (e.key === "f" || e.key === "F") { + if (e.key === 'ArrowRight') { + e.preventDefault(); + next(); + } else if (e.key === 'ArrowLeft') { + e.preventDefault(); + prev(); + } else if (e.key === 'Escape') { + setOpen(false); + } else if (e.key === 'f' || e.key === 'F') { e.preventDefault(); if (!document.fullscreenElement) { - document.documentElement.requestFullscreen().catch(() => { /* noop */ }); + document.documentElement.requestFullscreen().catch(() => { + /* noop */ + }); } else { - document.exitFullscreen().catch(() => { /* noop */ }); + document.exitFullscreen().catch(() => { + /* noop */ + }); } } }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); }, [open, next, prev]); useEffect(() => { @@ -59,27 +69,39 @@ export function ComparisonPresentationLauncher({ products, formatCurrency, trigg setOpen(true)}> {trigger ?? ( )} - -
+ +
{/* Header bar */} -
+
- {slide + 1} / {totalSlides} + + {slide + 1} / {totalSlides} + - {isFinal ? "Resumo comparativo" : products[slide]?.name} + {isFinal ? 'Resumo comparativo' : products[slide]?.name}
- -
@@ -88,16 +110,25 @@ export function ComparisonPresentationLauncher({ products, formatCurrency, trigg {/* Slide content */}
{isFinal ? ( - + ) : ( - + )}
{/* Nav controls */} -
+
@@ -107,15 +138,15 @@ export function ComparisonPresentationLauncher({ products, formatCurrency, trigg onClick={() => setSlide(i)} aria-label={`Ir para slide ${i + 1}`} className={cn( - "h-2 rounded-full transition-all", - i === slide ? "w-8 bg-primary" : "w-2 bg-muted hover:bg-muted-foreground/40" + 'h-2 rounded-full transition-all', + i === slide ? 'w-8 bg-primary' : 'w-2 bg-muted hover:bg-muted-foreground/40', )} /> ))}
@@ -125,7 +156,12 @@ export function ComparisonPresentationLauncher({ products, formatCurrency, trigg ); } -function ProductSlide({ product, idx, formatCurrency, isWinner }: { +function ProductSlide({ + product, + idx, + formatCurrency, + isWinner, +}: { product: Product; idx: number; formatCurrency: (v: number) => string; @@ -133,11 +169,16 @@ function ProductSlide({ product, idx, formatCurrency, isWinner }: { }) { if (!product) return null; return ( -
-
- {product.name} +
+
+ {product.name} {isWinner && ( - + Recomendado @@ -145,16 +186,20 @@ function ProductSlide({ product, idx, formatCurrency, isWinner }: {

Produto {idx + 1}

-

{product.name}

-

{formatCurrency(product.price)}

+

+ {product.name} +

+

+ {formatCurrency(product.price)} +

- +
{product.description && ( -

{product.description}

+

{product.description}

)}
@@ -164,46 +209,75 @@ function ProductSlide({ product, idx, formatCurrency, isWinner }: { function Stat({ label, value }: { label: string; value: string | number }) { return (
-

{label}

-

{value}

+

{label}

+

{value}

); } -function FinalSlide({ products, formatCurrency, winnerIdx }: { +function FinalSlide({ + products, + formatCurrency, + winnerIdx, +}: { products: Product[]; formatCurrency: (v: number) => string; winnerIdx: number; }) { return ( -
-

Resumo comparativo

+
+

+ Resumo comparativo +

- - - - - + + + + + {products.map((p, idx) => ( - + - + @@ -212,7 +286,7 @@ function FinalSlide({ products, formatCurrency, winnerIdx }: {
ProdutoPreçoQtd. mín.EstoqueCores + Produto + + Preço + + Qtd. mín. + + Estoque + + Cores +
- {p.name} + {p.name}

{p.name}

{winnerIdx === idx && ( - Recomendado + + + Recomendado + )}
{formatCurrency(p.price)} + {formatCurrency(p.price)} + {p.minQuantity ?? 0} {p.stock ?? 0} {p.colors?.length ?? 0}
-

+

Use ← → para navegar · F para tela cheia · Esc para sair

diff --git a/src/components/compare/ComparisonRadarChart.tsx b/src/components/compare/ComparisonRadarChart.tsx index 6cef53aad..bbcd76ea2 100644 --- a/src/components/compare/ComparisonRadarChart.tsx +++ b/src/components/compare/ComparisonRadarChart.tsx @@ -2,8 +2,8 @@ * ComparisonRadarChart — Radar visual de até 5 dimensões para múltiplos produtos. * Eixos: Preço (invertido), Estoque, Variedade de cores, Qtd mínima (invertido), Lead time (invertido). */ -import { useMemo } from "react"; -import type { Product } from "@/types/product-catalog"; +import { useMemo } from 'react'; +import type { Product } from '@/types/product-catalog'; import { Radar, RadarChart, @@ -13,7 +13,7 @@ import { ResponsiveContainer, Legend, Tooltip, -} from "recharts"; +} from 'recharts'; interface ComparisonRadarChartProps { products: Product[]; @@ -21,19 +21,23 @@ interface ComparisonRadarChartProps { } const COLORS = [ - "hsl(var(--primary))", - "hsl(var(--success))", - "hsl(var(--warning))", - "hsl(var(--destructive))", + 'hsl(var(--primary))', + 'hsl(var(--success))', + 'hsl(var(--warning))', + 'hsl(var(--destructive))', ]; function leadTimeScore(status: string | undefined): number { // Higher is better in radar (already inverted) switch (status) { - case "in-stock": return 100; - case "low-stock": return 60; - case "out-of-stock": return 20; - default: return 50; + case 'in-stock': + return 100; + case 'low-stock': + return 60; + case 'out-of-stock': + return 20; + default: + return 50; } } @@ -41,10 +45,10 @@ export function ComparisonRadarChart({ products, className }: ComparisonRadarCha const data = useMemo(() => { if (!products || products.length === 0) return []; - const prices = products.map(p => Number(p.price ?? 0)); - const stocks = products.map(p => Number(p.stock ?? 0)); - const mins = products.map(p => Number(p.minQuantity ?? 1)); - const colorCounts = products.map(p => p.colors?.length ?? 0); + const prices = products.map((p) => Number(p.price ?? 0)); + const stocks = products.map((p) => Number(p.stock ?? 0)); + const mins = products.map((p) => Number(p.minQuantity ?? 1)); + const colorCounts = products.map((p) => p.colors?.length ?? 0); const maxPrice = Math.max(...prices, 1); const maxStock = Math.max(...stocks, 1); @@ -52,14 +56,17 @@ export function ComparisonRadarChart({ products, className }: ComparisonRadarCha const maxColors = Math.max(...colorCounts, 1); const axes = [ - { key: "Preço", values: prices.map(v => Math.round((1 - v / maxPrice) * 100)) }, - { key: "Estoque", values: stocks.map(v => Math.round((v / maxStock) * 100)) }, - { key: "Cores", values: colorCounts.map(v => Math.round((v / maxColors) * 100)) }, - { key: "Qtd. mín", values: mins.map(v => Math.round((1 - (v - 1) / Math.max(1, maxMin - 1)) * 100)) }, - { key: "Lead time", values: products.map(p => leadTimeScore(p.stockStatus)) }, + { key: 'Preço', values: prices.map((v) => Math.round((1 - v / maxPrice) * 100)) }, + { key: 'Estoque', values: stocks.map((v) => Math.round((v / maxStock) * 100)) }, + { key: 'Cores', values: colorCounts.map((v) => Math.round((v / maxColors) * 100)) }, + { + key: 'Qtd. mín', + values: mins.map((v) => Math.round((1 - (v - 1) / Math.max(1, maxMin - 1)) * 100)), + }, + { key: 'Lead time', values: products.map((p) => leadTimeScore(p.stockStatus)) }, ]; - return axes.map(axis => { + return axes.map((axis) => { const row: Record = { axis: axis.key }; products.forEach((p, i) => { row[String(p.id)] = Math.max(0, Math.min(100, axis.values[i])); @@ -73,31 +80,34 @@ export function ComparisonRadarChart({ products, className }: ComparisonRadarCha return (
-

- +

+ Radar comparativo (0–100, maior é melhor)

- + { - const p = products.find(x => String(x.id) === name); + const p = products.find((x) => String(x.id) === name); return [value, p?.name ?? name]; }} /> { - const p = products.find(x => String(x.id) === value); + const p = products.find((x) => String(x.id) === value); return p?.name?.slice(0, 28) ?? value; }} /> diff --git a/src/components/compare/ComparisonScoreCard.tsx b/src/components/compare/ComparisonScoreCard.tsx index dcbf32632..0ec05c20c 100644 --- a/src/components/compare/ComparisonScoreCard.tsx +++ b/src/components/compare/ComparisonScoreCard.tsx @@ -15,9 +15,10 @@ import { DEFAULT_SCORE_WEIGHTS, type ComparisonScoreWeights, } from '@/hooks/comparison'; +import type { Product } from '@/types/product-catalog'; interface ComparisonScoreCardProps { - products: Record[]; + products: Product[]; className?: string; } diff --git a/src/components/compare/ExportComparisonButton.tsx b/src/components/compare/ExportComparisonButton.tsx index 3e59728d7..1e9013bae 100644 --- a/src/components/compare/ExportComparisonButton.tsx +++ b/src/components/compare/ExportComparisonButton.tsx @@ -1,65 +1,78 @@ /** * ExportComparisonButton — Exporta comparação em PDF (A4 paisagem) / PNG / CSV. */ -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { Download, FileText, Image as ImageIcon, FileSpreadsheet, Loader2 } from "lucide-react"; -import { toast } from "sonner"; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Download, FileText, Image as ImageIcon, FileSpreadsheet, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import type { Product } from '@/types/product-catalog'; interface Props { - products: Record[]; + products: Product[]; targetSelector?: string; // CSS selector for PNG capture formatCurrency: (v: number) => string; } -export function ExportComparisonButton({ products, targetSelector = "#compare-export-area", formatCurrency }: Props) { +export function ExportComparisonButton({ + products, + targetSelector = '#compare-export-area', + formatCurrency: _formatCurrency, +}: Props) { const [busy, setBusy] = useState(false); const exportCSV = () => { - const headers = ["SKU", "Nome", "Preço", "Qtd. mín.", "Estoque", "Cores", "Categoria"]; - const rows = products.map(p => [ - p.sku ?? "", + const headers = ['SKU', 'Nome', 'Preço', 'Qtd. mín.', 'Estoque', 'Cores', 'Categoria']; + const rows = products.map((p) => [ + p.sku ?? '', p.name, p.price, p.minQuantity, p.stock, - (p.colors?.length ?? 0), - p.category?.name ?? "", + p.colors?.length ?? 0, + p.category?.name ?? '', ]); - const csv = [headers, ...rows].map(r => - r.map(v => `"${String(v ?? "").replace(/"/g, '""')}"`).join(",") - ).join("\n"); - const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" }); + const csv = [headers, ...rows] + .map((r) => r.map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',')) + .join('\n'); + const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); - const a = document.createElement("a"); + const a = document.createElement('a'); a.href = url; a.download = `comparacao-${new Date().toISOString().slice(0, 10)}.csv`; a.click(); URL.revokeObjectURL(url); - toast.success("CSV exportado"); + toast.success('CSV exportado'); }; const exportPNG = async () => { setBusy(true); try { - const html2canvas = (await import("html2canvas")).default; + const html2canvas = (await import('html2canvas')).default; const el = document.querySelector(targetSelector) as HTMLElement | null; - if (!el) { toast.error("Área não encontrada"); return; } - const canvas = await html2canvas(el, { backgroundColor: "#ffffff", scale: 2, useCORS: true }); + if (!el) { + toast.error('Área não encontrada'); + return; + } + const canvas = await html2canvas(el, { backgroundColor: '#ffffff', scale: 2, useCORS: true }); canvas.toBlob((blob) => { if (!blob) return; const url = URL.createObjectURL(blob); - const a = document.createElement("a"); + const a = document.createElement('a'); a.href = url; a.download = `comparacao-${new Date().toISOString().slice(0, 10)}.png`; a.click(); URL.revokeObjectURL(url); - toast.success("PNG exportado"); + toast.success('PNG exportado'); }); } catch (e) { console.error(e); - toast.error("Falha ao exportar PNG"); + toast.error('Falha ao exportar PNG'); } finally { setBusy(false); } @@ -69,35 +82,38 @@ export function ExportComparisonButton({ products, targetSelector = "#compare-ex setBusy(true); try { const [{ default: jsPDF }, html2canvasMod] = await Promise.all([ - import("jspdf"), - import("html2canvas"), + import('jspdf'), + import('html2canvas'), ]); const html2canvas = html2canvasMod.default; const el = document.querySelector(targetSelector) as HTMLElement | null; - if (!el) { toast.error("Área não encontrada"); return; } - const canvas = await html2canvas(el, { backgroundColor: "#ffffff", scale: 2, useCORS: true }); - const imgData = canvas.toDataURL("image/png"); + if (!el) { + toast.error('Área não encontrada'); + return; + } + const canvas = await html2canvas(el, { backgroundColor: '#ffffff', scale: 2, useCORS: true }); + const imgData = canvas.toDataURL('image/png'); - const pdf = new jsPDF({ orientation: "landscape", unit: "mm", format: "a4" }); + const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' }); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const imgWidth = pageWidth - 20; const imgHeight = (canvas.height * imgWidth) / canvas.width; pdf.setFontSize(16); - pdf.text("Comparação de Produtos — Promo Gifts", 10, 12); + pdf.text('Comparação de Produtos — Promo Gifts', 10, 12); pdf.setFontSize(9); - pdf.text(new Date().toLocaleDateString("pt-BR"), pageWidth - 30, 12); + pdf.text(new Date().toLocaleDateString('pt-BR'), pageWidth - 30, 12); if (imgHeight < pageHeight - 25) { - pdf.addImage(imgData, "PNG", 10, 18, imgWidth, imgHeight); + pdf.addImage(imgData, 'PNG', 10, 18, imgWidth, imgHeight); } else { // multiple pages let position = 18; let remaining = imgHeight; const sliceHeight = pageHeight - 25; while (remaining > 0) { - pdf.addImage(imgData, "PNG", 10, position - (imgHeight - remaining), imgWidth, imgHeight); + pdf.addImage(imgData, 'PNG', 10, position - (imgHeight - remaining), imgWidth, imgHeight); remaining -= sliceHeight; if (remaining > 0) { pdf.addPage(); @@ -106,10 +122,10 @@ export function ExportComparisonButton({ products, targetSelector = "#compare-ex } } pdf.save(`comparacao-${new Date().toISOString().slice(0, 10)}.pdf`); - toast.success("PDF exportado"); + toast.success('PDF exportado'); } catch (e) { console.error(e); - toast.error("Falha ao exportar PDF"); + toast.error('Falha ao exportar PDF'); } finally { setBusy(false); } @@ -119,19 +135,23 @@ export function ExportComparisonButton({ products, targetSelector = "#compare-ex - PDF (paisagem) + PDF (paisagem) - PNG (imagem) + PNG (imagem) - CSV (planilha) + CSV (planilha) diff --git a/src/components/compare/FloatingCompareBar.tsx b/src/components/compare/FloatingCompareBar.tsx index f10ebc3fd..97a3d6bec 100644 --- a/src/components/compare/FloatingCompareBar.tsx +++ b/src/components/compare/FloatingCompareBar.tsx @@ -1,158 +1,163 @@ -import React, { useMemo } from "react"; -import { motion, AnimatePresence } from "framer-motion"; -import { useNavigate } from "react-router-dom"; -import { GitCompare, X, ChevronRight, Trash2 } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useComparisonStore, type CompareVariantInfo } from "@/stores/useComparisonStore"; -import { useProductsContextSafe } from "@/contexts/ProductsContext"; -import type { Product } from "@/types/product-catalog"; -import { cn } from "@/lib/utils"; +import React, { useMemo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useNavigate } from 'react-router-dom'; +import { GitCompare, X, ChevronRight, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useComparisonStore, type CompareVariantInfo } from '@/stores/useComparisonStore'; +import { useProductsContextSafe } from '@/contexts/ProductsContext'; +import type { Product } from '@/types/product-catalog'; +import { cn } from '@/lib/utils'; export const FloatingCompareBar = React.forwardRef( - function FloatingCompareBar(_props, ref) { - const navigate = useNavigate(); - const { compareItems, removeByIndex, clearCompare, compareCount } = - useComparisonStore(); - const ctx = useProductsContextSafe(); - const getProductsByIds = ctx?.getProductsByIds; - const cacheSignal = ctx?.products; + function FloatingCompareBar(_props, _ref) { + const navigate = useNavigate(); + const { compareItems, removeByIndex, clearCompare, compareCount } = useComparisonStore(); + const ctx = useProductsContextSafe(); + const getProductsByIds = ctx?.getProductsByIds; + const cacheSignal = ctx?.products; - const compareEntries = useMemo(() => { - if (!getProductsByIds) return []; - const uniqueIds = [...new Set(compareItems.map(i => i.productId))]; - const productMap = new Map(); - getProductsByIds(uniqueIds).forEach((p: Product) => productMap.set(p.id, p)); + const compareEntries = useMemo(() => { + if (!getProductsByIds) return []; + const uniqueIds = [...new Set(compareItems.map((i) => i.productId))]; + const productMap = new Map(); + getProductsByIds(uniqueIds).forEach((p: Product) => productMap.set(p.id, p)); - return compareItems.map((item, index) => { - const product = productMap.get(item.productId); - if (!product) return null; - const displayProduct = item.variant?.thumbnail - ? { ...product, images: [item.variant.thumbnail, ...product.images] } - : product; - return { product: displayProduct, variant: item.variant, index }; - }).filter(Boolean) as { product: Product; variant?: CompareVariantInfo; index: number }[]; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [compareItems, getProductsByIds, cacheSignal]); + return compareItems + .map((item, index) => { + const product = productMap.get(item.productId); + if (!product) return null; + const displayProduct = item.variant?.thumbnail + ? { ...product, images: [item.variant.thumbnail, ...product.images] } + : product; + return { product: displayProduct, variant: item.variant, index }; + }) + .filter(Boolean) as { product: Product; variant?: CompareVariantInfo; index: number }[]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [compareItems, getProductsByIds, cacheSignal]); - if (compareCount === 0) return null; + if (compareCount === 0) return null; - return ( - - - {/* Icon */} -
- -
+ return ( + + + {/* Icon */} +
+ +
- {/* Product Thumbnails */} -
- {compareEntries.map((entry, idx) => ( - - - -
- {entry.product.name} -
-
- -

- {entry.product.name} - {entry.variant?.color_name && ` — ${entry.variant.color_name}`} -

-
-
- - {/* Remove button */} - + + +
+ {entry.product.name} +
+
+ +

+ {entry.product.name} + {entry.variant?.color_name && ` — ${entry.variant.color_name}`} +

+
+
- {/* Color dot indicator */} - {entry.variant?.color_hex && ( -
- )} - - ))} + {/* Remove button */} + - {/* Empty slots */} - {Array.from({ length: 4 - compareCount }).map((_, idx) => ( -
- + -
- ))} -
+ {/* Color dot indicator */} + {entry.variant?.color_hex && ( +
+ )} + + ))} + + {/* Empty slots */} + {Array.from({ length: 4 - compareCount }).map((_, idx) => ( +
+ + +
+ ))} +
- {/* Divider */} -
+ {/* Divider */} +
- {/* Actions */} -
- - - - - Limpar comparação - + {/* Actions */} +
+ + + + + Limpar comparação + - -
- - - ); - } + +
+ + + ); + }, ); diff --git a/src/components/compare/OtherSuppliersRow.tsx b/src/components/compare/OtherSuppliersRow.tsx index 6c35f79f1..87f31401e 100644 --- a/src/components/compare/OtherSuppliersRow.tsx +++ b/src/components/compare/OtherSuppliersRow.tsx @@ -2,13 +2,13 @@ * OtherSuppliersRow — linha expansível mostrando alternativas de outros fornecedores. * Usa useSupplierComparison existente. */ -import { useState } from "react"; -import { ChevronDown, Building2, TrendingDown } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useSupplierComparison } from "@/hooks/products"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import type { Product } from "@/types/product-catalog"; +import { useState } from 'react'; +import { ChevronDown, Building2, TrendingDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useSupplierComparison } from '@/hooks/products'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import type { Product } from '@/types/product-catalog'; interface Props { product: Product; @@ -24,43 +24,50 @@ export function OtherSuppliersRow({ product, formatCurrency, onAddToCompare }: P
{open && ( -
+
{isLoading && ( -

+

Buscando alternativas...

)} {result && result.alternatives.length === 0 && ( -

+

Nenhum fornecedor alternativo encontrado.

)} - {result?.alternatives.slice(0, 3).map(alt => ( + {result?.alternatives.slice(0, 3).map((alt) => (
-

{alt.product.supplier?.name}

-

{alt.product.name}

+

{alt.product.supplier?.name}

+

{alt.product.name}

-
-

{formatCurrency(alt.product.price)}

+
+

+ {formatCurrency(alt.product.price)} +

{alt.priceDiff < 0 && ( - + {alt.priceDiffPercent.toFixed(1)}% )}
{onAddToCompare && ( - )} diff --git a/src/components/compare/SimilarProductsRail.tsx b/src/components/compare/SimilarProductsRail.tsx index 6c5739239..e062aae9d 100644 --- a/src/components/compare/SimilarProductsRail.tsx +++ b/src/components/compare/SimilarProductsRail.tsx @@ -2,25 +2,29 @@ * SimilarProductsRail — bottom rail "Compare também com..." 4-6 produtos similares. * Mesma categoria + faixa de preço ±20% dos produtos já em comparação. */ -import { useMemo } from "react"; -import { useProducts, type Product } from "@/hooks/products"; -import { useComparisonStore } from "@/stores/useComparisonStore"; -import { Button } from "@/components/ui/button"; -import { Plus, Sparkles } from "lucide-react"; -import { toast } from "sonner"; +import { useMemo } from 'react'; +import { useProducts } from '@/hooks/products'; +import { useComparisonStore } from '@/stores/useComparisonStore'; +import { Button } from '@/components/ui/button'; +import { Plus, Sparkles } from 'lucide-react'; +import { toast } from 'sonner'; +import type { Product } from '@/types/product-catalog'; interface Props { - products: Record[]; + products: Product[]; formatCurrency: (v: number) => string; } export function SimilarProductsRail({ products, formatCurrency }: Props) { const { addToCompare, isInCompare, canAddMore } = useComparisonStore(); const primaryCategory = products[0]?.category?.name; - const { data: pool = [] } = useProducts( - primaryCategory ? { category: primaryCategory } : undefined, - { enabled: !!primaryCategory, staleTime: 10 * 60 * 1000 } - ); + const { data } = useProducts(primaryCategory ? { category: primaryCategory } : undefined, { + enabled: !!primaryCategory, + staleTime: 10 * 60 * 1000, + }); + const pool = useMemo((): Product[] => { + return Array.isArray(data) ? (data as Product[]) : []; + }, [data]); const suggestions = useMemo(() => { if (!pool.length || !products.length) return []; @@ -29,7 +33,7 @@ export function SimilarProductsRail({ products, formatCurrency }: Props) { const minP = avgPrice * 0.8; const maxP = avgPrice * 1.2; return pool - .filter((p: Product) => !compareIds.has(p.id) && p.price >= minP && p.price <= maxP) + .filter((p) => !compareIds.has(p.id) && p.price >= minP && p.price <= maxP) .slice(0, 6); }, [pool, products]); @@ -37,7 +41,7 @@ export function SimilarProductsRail({ products, formatCurrency }: Props) { const handleAdd = (id: string, name: string) => { if (!canAddMore) { - toast.error("Máximo 4 produtos"); + toast.error('Máximo 4 produtos'); return; } if (addToCompare(id)) toast.success(`${name} adicionado à comparação`); @@ -49,23 +53,31 @@ export function SimilarProductsRail({ products, formatCurrency }: Props) {

Compare também com…

-
- {suggestions.map((p: Product) => ( -
-
- {p.name} +
+ {suggestions.map((p) => ( +
+
+ {p.name}
-

{p.name}

-

{formatCurrency(p.price)}

+

{p.name}

+

{formatCurrency(p.price)}

))} diff --git a/src/components/compare/StockRiskBadge.tsx b/src/components/compare/StockRiskBadge.tsx index 4b0a4384a..b062c1af6 100644 --- a/src/components/compare/StockRiskBadge.tsx +++ b/src/components/compare/StockRiskBadge.tsx @@ -2,9 +2,9 @@ * StockRiskBadge — exibe risco de estoque baseado em estoque atual vs giro. * Sem hook futureStock disponível, usamos heurística: stock <= minQuantity * 2 → risco. */ -import { Badge } from "@/components/ui/badge"; -import { AlertTriangle, ShieldCheck } from "lucide-react"; -import type { Product } from "@/types/product-catalog"; +import { Badge } from '@/components/ui/badge'; +import { AlertTriangle, ShieldCheck } from 'lucide-react'; +import type { Product } from '@/types/product-catalog'; interface Props { product: Product; @@ -15,22 +15,25 @@ export function StockRiskBadge({ product }: Props) { const min = product.minQuantity ?? 1; const status = product.stockStatus; - if (status === "out-of-stock" || stock === 0) { + if (status === 'out-of-stock' || stock === 0) { return ( - + Sem estoque ); } - if (stock <= min * 2 || status === "low-stock") { + if (stock <= min * 2 || status === 'low-stock') { return ( - + Risco < 30d ); } return ( - + Estável ); diff --git a/src/pages/products/ComparePage.tsx b/src/pages/products/ComparePage.tsx index e3b0f2a59..038a85dd4 100644 --- a/src/pages/products/ComparePage.tsx +++ b/src/pages/products/ComparePage.tsx @@ -42,7 +42,7 @@ import { SimilarProductsRail } from '@/components/compare/SimilarProductsRail'; import { CompareEmptyStateSmart } from '@/components/compare/CompareEmptyStateSmart'; import { RecentComparisonsSidebar } from '@/components/compare/RecentComparisonsSidebar'; import { FavoritesClientPicker } from '@/components/favorites/FavoritesClientPicker'; -import { useComparisonShortcuts, useComparisonSync } from "@/hooks/comparison"; +import { useComparisonShortcuts, useComparisonSync } from '@/hooks/comparison'; export default function ComparePage() { useComparisonSync(); @@ -120,274 +120,274 @@ export default function ComparePage() { // Empty state with smart suggestions if (compareCount < 2) { return ( - <> - - - + <> + + + ); } return ( - <> - {/* ARIA-live region for accessibility announcements */} -
- {ariaMessage} + <> + {/* ARIA-live region for accessibility announcements */} +
+ {ariaMessage} +
+ + +
+
+
+ +
+

+ Comparador de Produtos +

+

+ Comparando {compareCount} produtos + {client && ( + <> + {' '} + · {client.name} + + )} +

+
+
+
+ + + + + + + + + + + + + + + +
- navigate(`/produto/${id}`)} /> -
-
-
- -
-

- Comparador de Produtos -

-

- Comparando {compareCount} produtos - {client && ( - <> - {' '} - · {client.name} - - )} -

-
-
-
- - - - - - - - - - - - - - + + {/* Desktop view (>=768px) */} +
+ {/* Score + Radar */} +
+ + {showRadar && } +
+ + + {/* Duel mode toggle (only visible when 2 products) */} + {compareCount === 2 && ( +
-
- - {/* Mobile carousel view (<768px) */} - navigate(`/produto/${id}`)} - /> + )} - {/* Desktop view (>=768px) */} -
- {/* Score + Radar */} -
- - {showRadar && } -
- + {compareCount === 2 && duelMode ? ( + navigate(`/produto/${id}`)} + /> + ) : ( + + + + + Galeria Visual + + + + Tabela Detalhada + + - {/* Duel mode toggle (only visible when 2 products) */} - {compareCount === 2 && ( -
- -
- )} - - {compareCount === 2 && duelMode ? ( - navigate(`/produto/${id}`)} - /> - ) : ( - - - - - Galeria Visual - - - - Tabela Detalhada - - - - - navigate(`/produto/${id}`)} - /> -
= 4 && 'grid-cols-2 lg:grid-cols-4', - )} - > - {compareEntries.map((entry) => { - const status = getStockStatusLabel(entry.product.stockStatus); - return ( -
+ {compareEntries.map((entry) => { + const status = getStockStatusLabel(entry.product.stockStatus); + return ( +
+
+
+ + {formatCurrency(entry.product.price)} + + {entry.variant?.color_name && ( + + {entry.variant.color_hex && ( + + )} + {entry.variant.color_name} + + )} +
+ +
+
+
+ Mín: + {entry.product.minQuantity} un. +
+
+ Estoque: + {status.label} +
-
- - {formatCurrency(entry.product.price)} - - {entry.variant?.color_name && ( - - {entry.variant.color_hex && ( - - )} - {entry.variant.color_name} - + Cores: +
+ {entry.product.colors + .slice(0, 4) + .map((c: ProductColor, i: number) => ( +
+ ))} + {entry.product.colors.length > 4 && ( + + +{entry.product.colors.length - 4} + )}
-
-
-
- Mín: - {entry.product.minQuantity} un. -
-
- Estoque: - {status.label} -
-
- Cores: -
- {entry.product.colors - .slice(0, 4) - .map((c: ProductColor, i: number) => ( -
- ))} - {entry.product.colors.length > 4 && ( - - +{entry.product.colors.length - 4} - - )} -
-
-
-
- ); - })} -
- + +
+ ); + })} +
+ - - - - - )} + + + + + )} - {/* Bottom rail — Compare também com... */} - -
+ {/* Bottom rail — Compare também com... */} +
- +
+ ); }