diff --git a/src/components/compare/CompareTableView.tsx b/src/components/compare/CompareTableView.tsx index d979ad9c6..e5143b6f2 100644 --- a/src/components/compare/CompareTableView.tsx +++ b/src/components/compare/CompareTableView.tsx @@ -3,23 +3,28 @@ * 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, ShieldCheck } 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"; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { X, Check, Minus, Crown, AlertTriangle, ShieldCheck } 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'; interface CompareEntry { product: Product; @@ -38,34 +43,56 @@ 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); } /** Lê com segurança um array de strings de um campo do JSONB `tags`. */ -function tagArray(tags: Product["tags"], key: string): string[] { +function tagArray(tags: Product['tags'], key: string): string[] { const v = tags?.[key]; return Array.isArray(v) ? (v as string[]) : []; } +function getMinQuantity(product: Product): number { + return product.min_quantity ?? (product as Product & { minQuantity?: number }).minQuantity ?? 0; +} + +function getStockStatus(product: Product): string | undefined { + return ( + product.stock_status ?? (product as Product & { stockStatus?: string }).stockStatus ?? undefined + ); +} + +function isKitProduct(product: Product): boolean { + return Boolean(product.is_kit ?? (product as Product & { isKit?: boolean }).isKit); +} + export function CompareTableView({ entries, products, @@ -83,25 +110,34 @@ 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.is_kit)), - materials: allEqual(products.map(p => (p.materials ?? []).slice().sort().join("|"))), - publico: allEqual(products.map(p => tagArray(p.tags, "publicoAlvo").slice().sort().join("|"))), - datas: allEqual(products.map(p => tagArray(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.is_kit)), + materials: allEqual(products.map((p) => (p.materials ?? []).slice().sort().join('|'))), + publico: allEqual( + products.map((p) => tagArray(p.tags, 'publicoAlvo').slice().sort().join('|')), + ), + datas: allEqual( + products.map((p) => tagArray(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]; @@ -118,22 +154,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)} +
))}
@@ -146,7 +186,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) => ( -
)} - } /> + } + />
@@ -206,13 +271,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) => ( @@ -221,77 +294,234 @@ export function CompareTableView({ ))}
- p.min_quantity ?? 0} renderFn={(v) => `${v} un.`} mode="lower-is-better" /> - Number(p.price ?? 0) * Number(p.min_quantity ?? 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.stock_status ?? 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" /> + getMinQuantity(p)} + renderFn={(v) => `${v} un.`} + mode="lower-is-better" + /> + Number(p.price ?? 0) * Number(getMinQuantity(p) || 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(getStockStatus(p))} + 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?.icon} {p.category?.name}} />} - {showRow("supplier") && ( - ( -
- {p.supplier?.verified && } - {p.supplier?.name} -
- )} /> + {showRow('sku') && ( + {p.sku}} + /> )} - { - const s = getStockStatusLabel(p.stock_status ?? ""); - return ({s.label}); - }} /> - {showRow("isKit") && p.is_kit ? : } />} - ( -
- {(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?.icon} {p.category?.name} + + )} + /> + )} + {showRow('supplier') && ( + ( +
+ {p.supplier?.verified && } + {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(getStockStatus(p) ?? ''); + return {s.label}; + }} + /> + {showRow('isKit') && ( + + isKitProduct(p) ? ( + + ) : ( + + ) + } + /> )} - {showRow("publico") && ( - ( + (
- {tagArray(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 ?? 0) - 6} + + )}
- )} /> + )} + /> + {showRow('materials') && ( + ( +
+ {(p.materials ?? []).map((m: string) => ( + + {m} + + ))} +
+ )} + /> )} - {showRow("datas") && ( - { - const datas = tagArray(p.tags, "datasComemorativas"); - return datas.length > 0 - ?
{datas.slice(0, 2).map((d: string) => {d})}
- : ; - }} /> + {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') && ( + ( +
+ {tagArray(p.tags, 'publicoAlvo') + .slice(0, 3) + .map((t: string) => ( + + {t} + + ))} +
+ )} + /> + )} + {showRow('datas') && ( + { + const datas = tagArray(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) => ( - } formatCurrency={formatCurrency} /> + } + formatCurrency={formatCurrency} + /> ))} - } /> + ( + + )} + />
@@ -301,46 +531,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' && }
))}