Skip to content
Merged
2 changes: 2 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
166 changes: 166 additions & 0 deletions docs/redeploy/REDEPLOY-ETAPA-13-COMPARE-FOLDER.md
Original file line number Diff line number Diff line change
@@ -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<string, unknown>`), 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`.
Comment on lines +25 to +27

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<string, unknown>`

Vários componentes "fugiam" do problema declarando props como `Record<string, unknown>[]`, 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<string, unknown>; // ❌ 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<string, unknown>` → `Product` | 0 → 0 (preserva) |
| `src/components/compare/OtherSuppliersRow.tsx` | `Record<string, unknown>` → `Product`, remove `as any` | 0 → 0 (preserva) |
| `src/components/compare/ComparisonScoreCard.tsx` | `Record<string, unknown>` → `Product` | 2 → 0 |
| `src/components/compare/ExportComparisonButton.tsx` | `Record<string, unknown>` → `Product` | 2 → 0 |
| `src/components/compare/SimilarProductsRail.tsx` | `Record<string, unknown>` → `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<TQueryFnData>`. 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<string, unknown>` removidos

Padrão que continua válido para futura caça: ainda existem outros componentes no repo com props tipadas como `Record<string, unknown>[]` — quase sempre são candidatos a substituição por um tipo real.

```bash
$ grep -rln "Record<string, unknown>\[\]" src/ | grep -v test
# (lista para futura limpeza arquitetural)
```
87 changes: 52 additions & 35 deletions src/components/compare/AIComparisonAdvisor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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 {
Expand All @@ -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<AdvisorResult | null>(() =>
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,
Expand All @@ -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 },
});

Expand All @@ -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);
Expand All @@ -100,35 +113,39 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) {

return (
<div className="rounded-2xl border-[1.5px] border-accent/40 bg-gradient-to-br from-accent/10 via-background to-background p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-accent to-primary shadow-md">
<Brain className="h-5 w-5 text-primary-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Conselheiro IA</h3>
<Badge variant="outline" className="text-[10px] gap-1">
<h3 className="text-sm font-semibold">Conselheiro IA</h3>
<Badge variant="outline" className="gap-1 text-[10px]">
<Sparkles className="h-2.5 w-2.5" /> Lovable AI
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Análise contextual da sua comparação
</p>
<p className="text-xs text-muted-foreground">Análise contextual da sua comparação</p>
</div>
</div>
<Button
size="sm"
variant={result ? "outline" : "default"}
variant={result ? 'outline' : 'default'}
onClick={fetchAdvice}
disabled={loading}
>
{loading ? (
<><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />Analisando...</>
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Analisando...
</>
) : result ? (
<>Re-analisar</>
) : (
<><Sparkles className="h-3.5 w-3.5 mr-1.5" />Analisar com IA</>
<>
<Sparkles className="mr-1.5 h-3.5 w-3.5" />
Analisar com IA
</>
)}
</Button>
</div>
Expand All @@ -139,14 +156,14 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) {
<ul className="space-y-1.5 text-sm">
{result.bullets.map((b, i) => (
<li key={i} className="flex gap-2">
<span className="text-accent mt-0.5">•</span>
<span className="mt-0.5 text-accent">•</span>
<span className="text-foreground/90">{b}</span>
</li>
))}
</ul>
)}
{result.bestFor && Object.keys(result.bestFor).length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-3">
<div className="mt-3 grid grid-cols-1 gap-2 md:grid-cols-3">
{result.bestFor.highVolume && (
<BestForCard label="Para alto volume" value={result.bestFor.highVolume} />
)}
Expand All @@ -159,8 +176,8 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) {
</div>
)}
{result.rationale && (
<p className="text-xs text-muted-foreground italic mt-2 flex gap-1.5">
<AlertCircle className="h-3 w-3 shrink-0 mt-0.5" />
<p className="mt-2 flex gap-1.5 text-xs italic text-muted-foreground">
<AlertCircle className="mt-0.5 h-3 w-3 shrink-0" />
{result.rationale}
</p>
)}
Expand All @@ -173,10 +190,10 @@ export function AIComparisonAdvisor({ products }: AIComparisonAdvisorProps) {
function BestForCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border bg-card p-2.5">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{label}
</p>
<p className="text-sm font-medium text-foreground line-clamp-2 mt-0.5">{value}</p>
<p className="mt-0.5 line-clamp-2 text-sm font-medium text-foreground">{value}</p>
</div>
);
}
Loading
Loading