chore: add docker deployment configuration

This commit is contained in:
Marcio Bevervanso 2026-01-27 14:51:05 -03:00
parent de9bf86d94
commit ff07aecb45
12 changed files with 787 additions and 372 deletions

6
.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules
dist
.git
.env
.env.local
.DS_Store

View file

@ -26,6 +26,7 @@ const AppLayout: React.FC = () => {
const { session, authLoading } = useCRM();
const { theme } = useTheme();
const isLoginPage = location.pathname === '/login';
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); // Moved to top level
if (authLoading) {
return (
@ -89,8 +90,6 @@ const AppLayout: React.FC = () => {
}
// 3. DARK / PRO (Default) -> Sidebar + Header (Glassmorphism)
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
return (
<div className="flex h-screen bg-background text-foreground font-sans antialiased selection:bg-indigo-500/30 selection:text-indigo-200 overflow-hidden">
<Sidebar isOpen={isMobileMenuOpen} onClose={() => setIsMobileMenuOpen(false)} />

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
# Build Stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production Stage
FROM nginx:alpine
# Copy built assets from build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,142 +1,336 @@
import React from 'react';
import { Product, PLATFORMS, CalculationResult } from '../types';
import { Info, Target, TrendingUp, AlertCircle, CheckCircle2, ExternalLink } from 'lucide-react';
// components/MarketplaceAnalytic.tsx
import React, { useMemo, useState } from "react";
import { Product, PLATFORMS, CalculationResult } from "../types";
import { AlertCircle, ExternalLink, Loader2 } from "lucide-react";
import { supabase } from "../services/supabase";
interface Props {
product: Product;
overheadPercent: number;
useOverhead: boolean;
}
const MarketplaceAnalytic: React.FC<Props> = ({ product, overheadPercent, useOverhead }) => {
const costParaguay = product.priceBRL;
const overhead = useOverhead ? (costParaguay * (overheadPercent / 100)) : 0;
/**
* Fluxo novo:
* - Este componente NÃO busca marketplace automaticamente.
* - Ele mostra cards e, quando você clicar em Amazon/ML, chama a Edge Function `enrich-market`
* e preenche preço/url daquele marketplace (cacheado no backend).
*/
const MarketplaceAnalytic: React.FC<Props> = ({ product, overheadPercent }) => {
const [mpLoading, setMpLoading] = useState<Record<string, boolean>>({});
const [mpData, setMpData] = useState<
Record<
string,
{
priceBRL?: number | null;
url?: string;
title?: string;
cache?: "HIT" | "MISS";
}
>
>({});
const costParaguay = Number(product.priceBRL ?? 0);
const overhead = costParaguay * (overheadPercent / 100);
const totalCost = costParaguay + overhead;
const results: CalculationResult[] = PLATFORMS.map(platform => {
let platformPrice = product.marketPriceBRL || (totalCost * 1.5);
let platformUrl = '';
if (platform.name === 'Amazon' && product.amazonPrice) {
platformPrice = product.amazonPrice;
platformUrl = product.amazonUrl || '';
} else if (platform.name === 'Mercado Livre' && product.mlPrice) {
platformPrice = product.mlPrice;
platformUrl = product.mlUrl || '';
} else if (platform.name === 'Shopee' && product.shopeePrice) {
platformPrice = product.shopeePrice;
platformUrl = product.shopeeUrl || '';
} else if (platform.name === 'Facebook') {
platformPrice = product.marketPriceBRL || (totalCost * 1.6); // Meta de venda
const getKnownMarketplaceValue = (platformName: string) => {
// Prioriza dados que já existam no product (se você tiver salvo no DB)
if (platformName === "Amazon") {
return {
price: product.amazonPrice ?? null,
url: product.amazonUrl ?? "",
};
}
if (platformName === "Mercado Livre") {
return {
price: product.mlPrice ?? null,
url: product.mlUrl ?? "",
};
}
if (platformName === "Shopee") {
return {
price: product.shopeePrice ?? null,
url: product.shopeeUrl ?? "",
};
}
return { price: null, url: "" };
};
const fees = (platformPrice * platform.commission) + platform.fixedFee;
const netProfit = platformPrice - fees - totalCost;
const margin = (netProfit / platformPrice) * 100;
const mapPlatformToMarketplace = (platformName: string): "ml" | "amazon" | null => {
if (platformName === "Amazon") return "amazon";
if (platformName === "Mercado Livre") return "ml";
return null;
};
return {
platform: platform.name,
marketPrice: platformPrice,
totalCost,
fees,
netProfit,
margin,
url: platformUrl
};
});
const handleFetchMarketplace = async (platformName: string) => {
const mp = mapPlatformToMarketplace(platformName);
if (!mp) return;
// Já tem no estado? não precisa refazer (você pode remover isso se quiser sempre refresh)
if (mpData[platformName]?.priceBRL && mpData[platformName]?.url) return;
setMpLoading((prev) => ({ ...prev, [platformName]: true }));
try {
const { data, error } = await supabase.functions.invoke("enrich-market", {
body: {
productName: product.name,
marketplace: mp,
pyUrl: product.url || product.pyUrl || "", // compat com campos diferentes
},
});
if (error) throw error;
// Esperado: { cache, marketplace, priceBRL, url, title, ... }
setMpData((prev) => ({
...prev,
[platformName]: {
priceBRL: typeof data?.priceBRL === "number" ? data.priceBRL : null,
url: typeof data?.url === "string" ? data.url : "",
title: typeof data?.title === "string" ? data.title : "",
cache: data?.cache === "HIT" ? "HIT" : "MISS",
},
}));
} catch (e) {
console.error("Erro ao buscar marketplace:", platformName, e);
// Mantém vazio; o card vai continuar em modo "clique para buscar"
} finally {
setMpLoading((prev) => ({ ...prev, [platformName]: false }));
}
};
const results: CalculationResult[] = useMemo(() => {
return PLATFORMS.map((platform) => {
const platformName = platform.name;
// 1) começa com preço padrão “meta”
let platformPrice =
typeof product.marketPriceBRL === "number"
? product.marketPriceBRL
: totalCost * (platformName === "Facebook" ? 1.6 : 1.5);
let platformUrl = "";
// 2) se já tem do product (salvo) usa
const known = getKnownMarketplaceValue(platformName);
if (typeof known.price === "number") platformPrice = known.price;
if (known.url) platformUrl = known.url;
// 3) se já buscou via Edge (state), sobrescreve
const fetched = mpData[platformName];
if (typeof fetched?.priceBRL === "number") platformPrice = fetched.priceBRL;
if (typeof fetched?.url === "string" && fetched.url) platformUrl = fetched.url;
// 4) taxas/margem
const fees = platformPrice * platform.commission + platform.fixedFee;
const netProfit = platformPrice - fees - totalCost;
const margin = platformPrice > 0 ? (netProfit / platformPrice) * 100 : 0;
return {
platform: platformName,
marketPrice: platformPrice,
totalCost,
fees,
netProfit,
margin,
url: platformUrl,
};
});
}, [product, totalCost, mpData]);
return (
<div className="space-y-6 animate-in fade-in duration-500">
<div className="bg-white/5 rounded-[32px] border border-white/5 shadow-sm overflow-hidden backdrop-blur-sm">
{/* Header de Info do Produto */}
{/* Header do produto */}
<div className="p-8 bg-white/5 border-b border-white/5">
<div className="flex items-center justify-between gap-6">
<div className="flex-grow">
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 px-3 py-1 rounded-full">Análise de Margem por Canal</span>
<span className="text-[10px] font-bold text-indigo-400 uppercase tracking-widest bg-indigo-500/10 px-3 py-1 rounded-full">
Análise de Margem por Canal
</span>
<h3 className="text-xl font-bold text-white leading-tight mt-3">{product.name}</h3>
<p className="text-xs text-slate-500 mt-1 font-medium italic">Referência Paraguay: {product.store}</p>
<p className="text-xs text-slate-500 mt-1 font-medium italic">
Referência Paraguay: {product.store}
</p>
</div>
<div className="text-right">
<span className="text-[10px] font-bold text-slate-500 uppercase block tracking-wider">Média Brasil</span>
<span className="text-2xl font-bold text-white tracking-tighter">R$ {(product.marketPriceBRL || 0).toLocaleString('pt-BR')}</span>
<span className="text-[10px] font-bold text-slate-500 uppercase block tracking-wider">
Média Brasil
</span>
<span className="text-2xl font-bold text-white tracking-tighter">
R$ {(product.marketPriceBRL || 0).toLocaleString("pt-BR")}
</span>
</div>
</div>
</div>
<div className="p-8">
{/* Breakdown de Custos */}
{/* Breakdown de custos */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
<div className="p-6 bg-white/5 border border-white/5 rounded-[24px] shadow-sm">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Custo PY</p>
<p className="text-xl font-bold text-slate-300">R$ {costParaguay.toLocaleString('pt-BR')}</p>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">
Custo PY
</p>
<p className="text-xl font-bold text-slate-300">
R$ {costParaguay.toLocaleString("pt-BR")}
</p>
</div>
<div className="p-6 bg-white/5 border border-white/5 rounded-[24px] shadow-sm">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">Custos Log/Fixo ({useOverhead ? `+${overheadPercent}%` : 'OFF'})</p>
<p className={`text-xl font-bold ${useOverhead ? 'text-amber-500' : 'text-slate-500 line-through'}`}>R$ {overhead.toLocaleString('pt-BR')}</p>
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-1">
Custos Log/Fixo (+{overheadPercent}%)
</p>
<p className="text-xl font-bold text-amber-500">
R$ {overhead.toLocaleString("pt-BR")}
</p>
</div>
<div className="p-6 bg-slate-800 rounded-[24px] shadow-xl shadow-black/20 border border-slate-700">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Custo Final BR</p>
<p className="text-2xl font-bold text-white tracking-tight">R$ {totalCost.toLocaleString('pt-BR')}</p>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">
Custo Final BR
</p>
<p className="text-2xl font-bold text-white tracking-tight">
R$ {totalCost.toLocaleString("pt-BR")}
</p>
</div>
</div>
<div className="flex items-center justify-between mb-6">
<h4 className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">Comparação em Tempo Real</h4>
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-2 py-1 rounded-lg">CLIQUE NO PREÇO PARA VER O ANÚNCIO</span>
<h4 className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
Comparação (clique para buscar preço)
</h4>
<span className="text-[10px] font-bold text-indigo-400 bg-indigo-500/10 px-2 py-1 rounded-lg">
CLIQUE NO MARKETPLACE PARA BUSCAR
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{results.map((res) => (
<div
key={res.platform}
className={`p-6 border border-white/10 rounded-[28px] transition-all relative group overflow-hidden bg-white/5 ${res.url ? 'hover:border-indigo-400/50 hover:shadow-lg hover:bg-white/10' : ''}`}
>
{res.url && (
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className="absolute inset-0 z-10"
title={`Ir para ${res.platform}`}
/>
)}
{results.map((res) => {
const isFetchable =
res.platform === "Amazon" || res.platform === "Mercado Livre";
const isLoading = !!mpLoading[res.platform];
const fetched = mpData[res.platform];
const hasOfferUrl = Boolean(res.url);
const hasFetchedPrice = typeof fetched?.priceBRL === "number";
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<span className="font-bold text-white tracking-tight">{res.platform}</span>
{res.url && <ExternalLink size={12} className="text-indigo-400 group-hover:text-indigo-300" />}
</div>
<div className={`px-3 py-1 rounded-xl text-[10px] font-bold ${res.margin > 15 ? 'bg-emerald-500/10 text-emerald-400' : 'bg-rose-500/10 text-rose-400'}`}>
MARGEM: {res.margin.toFixed(1)}%
</div>
</div>
return (
<div
key={res.platform}
className={`p-6 border border-white/10 rounded-[28px] transition-all relative group overflow-hidden bg-white/5 ${
hasOfferUrl ? "hover:border-indigo-400/50 hover:shadow-lg hover:bg-white/10" : ""
}`}
>
{/* overlay clicável */}
{hasOfferUrl ? (
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className="absolute inset-0 z-10"
title={`Ir para ${res.platform}`}
/>
) : isFetchable ? (
<button
type="button"
onClick={() => handleFetchMarketplace(res.platform)}
className="absolute inset-0 z-10"
title={`Buscar preço no ${res.platform}`}
/>
) : null}
<div className="space-y-3">
<div className="flex justify-between items-end">
<span className="text-[10px] font-bold text-slate-500 uppercase">Preço Praticado</span>
<span className="text-lg font-bold text-white leading-none">R$ {res.marketPrice.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}</span>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<span className="font-bold text-white tracking-tight">{res.platform}</span>
{hasOfferUrl && (
<ExternalLink size={12} className="text-indigo-400 group-hover:text-indigo-300" />
)}
{!hasOfferUrl && isFetchable && (
<span className="text-[9px] font-bold uppercase text-slate-400 bg-white/5 px-2 py-1 rounded-lg">
clique para buscar
</span>
)}
{isLoading && (
<span className="inline-flex items-center gap-1 text-[9px] font-bold uppercase text-slate-400">
<Loader2 className="animate-spin" size={12} />
buscando
</span>
)}
{hasFetchedPrice && fetched?.cache && (
<span className="text-[9px] font-bold uppercase text-slate-400 bg-white/5 px-2 py-1 rounded-lg">
cache {fetched.cache}
</span>
)}
</div>
<div
className={`px-3 py-1 rounded-xl text-[10px] font-bold ${
res.margin > 15 ? "bg-emerald-500/10 text-emerald-400" : "bg-rose-500/10 text-rose-400"
}`}
>
MARGEM: {Number.isFinite(res.margin) ? res.margin.toFixed(1) : "0.0"}%
</div>
</div>
<div className="flex justify-between text-xs font-semibold">
<span className="text-slate-500 font-bold uppercase text-[9px]">Taxas Plataforma</span>
<span className="text-rose-400">- R$ {res.fees.toFixed(2)}</span>
<div className="space-y-3">
<div className="flex justify-between items-end">
<span className="text-[10px] font-bold text-slate-500 uppercase">
Preço Praticado
</span>
<span className="text-lg font-bold text-white leading-none">
R{"$ "}
{res.marketPrice.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
<div className="flex justify-between text-xs font-semibold">
<span className="text-slate-500 font-bold uppercase text-[9px]">
Taxas Plataforma
</span>
<span className="text-rose-400">- R$ {res.fees.toFixed(2)}</span>
</div>
<div className="h-[1px] bg-white/5 my-1"></div>
<div className="flex justify-between items-center pt-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">
Lucro Líquido
</span>
<span
className={`text-xl font-bold tracking-tight ${
res.netProfit > 0 ? "text-emerald-400" : "text-rose-400"
}`}
>
R{"$ "}
{res.netProfit.toLocaleString("pt-BR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
</div>
<div className="h-[1px] bg-white/5 my-1"></div>
<div className="flex justify-between items-center pt-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Lucro Líquido</span>
<span className={`text-xl font-bold tracking-tight ${res.netProfit > 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
R$ {res.netProfit.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}
{/* Rodapé do card */}
<div className="mt-4 flex items-center justify-center gap-1.5 py-2 bg-white/5 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-all">
<span className="text-[9px] font-bold uppercase text-slate-400 group-hover:text-white">
{hasOfferUrl
? "Ver Oferta Real"
: isFetchable
? "Buscar oferta agora"
: "Sem busca automática"}
</span>
</div>
</div>
{res.url && (
<div className="mt-4 flex items-center justify-center gap-1.5 py-2 bg-white/5 rounded-xl group-hover:bg-indigo-600 group-hover:text-white transition-all">
<span className="text-[9px] font-bold uppercase text-slate-400 group-hover:text-white">Ver Oferta Real</span>
</div>
)}
</div>
))}
);
})}
</div>
</div>
@ -145,7 +339,8 @@ const MarketplaceAnalytic: React.FC<Props> = ({ product, overheadPercent, useOve
<AlertCircle size={14} />
</div>
<p className="leading-relaxed">
As margens exibidas consideram seu custo operacional fixo de {useOverhead ? `${overheadPercent}%` : '0%'}. Os preços dos marketplaces são extraídos em tempo real via busca web.
As margens consideram seu custo operacional fixo de {overheadPercent}%. Amazon/ML
consomem busca quando você clica no marketplace (economiza recursos).
</p>
</div>
</div>

View file

@ -139,7 +139,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
.from('profiles')
.select('role')
.eq('id', currentSession.user.id)
.single();
.maybeSingle();
if (profileError) {
console.error("Error fetching user profile:", profileError);
@ -283,6 +283,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
smtpPass: settingsData.smtp_pass,
autoSyncSales: settingsData.auto_sync_sales,
autoSyncStock: settingsData.auto_sync_stock,
sourcingWebhook: settingsData.sourcing_webhook,
});
}
} catch (err) {
@ -315,7 +316,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
smtp_user: newSettings.smtpUser,
smtp_pass: newSettings.smtpPass,
auto_sync_sales: newSettings.autoSyncSales,
auto_sync_stock: newSettings.autoSyncStock
auto_sync_stock: newSettings.autoSyncStock,
};
// Check if settings exist
@ -341,7 +342,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setSelectedProduct(null);
setProducts([]); // Clear previous results
try {
const { products: result } = await searchProducts(searchTerm);
const { products: result } = await searchProducts(searchTerm, exchangeRate);
setProducts(result);
if (result.length === 0) {
setSearchError("Não encontramos nenhum produto com esse nome. Tente termos mais gerais (ex: 'iPhone' em vez de 'iPhone 15 Pro Max 256GB Azul').");
@ -368,7 +369,7 @@ export const CRMProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
setProducts([]); // Clear previous results
try {
const { searchOpportunities } = await import('../services/geminiService');
const { products: result } = await searchOpportunities(category, useOverhead);
const { products: result } = await searchOpportunities(category, useOverhead, exchangeRate);
setProducts(result);
if (result.length === 0) {

28
migration_cache.sql Normal file
View file

@ -0,0 +1,28 @@
-- 8. SEARCH CACHE (Optimization)
create table if not exists public.search_cache (
id uuid default uuid_generate_v4() primary key,
query text not null,
type text default 'specific', -- 'specific' or 'opportunity'
results jsonb,
created_at timestamp with time zone default timezone('utc'::text, now()) not null
);
-- Index for faster lookups
create index if not exists idx_search_cache_query on public.search_cache(query);
-- Specific policy to allow read/write (if RLS is on, though usually service_role bypasses, but good to have)
alter table public.search_cache enable row level security;
create policy "Enable read access for authenticated users"
on "public"."search_cache"
as PERMISSIVE
for SELECT
to authenticated
using (true);
create policy "Enable insert access for authenticated users"
on "public"."search_cache"
as PERMISSIVE
for INSERT
to authenticated
with check (true);

29
migration_edge_cache.sql Normal file
View file

@ -0,0 +1,29 @@
-- Step 1: Recreate search_cache table based on new robust spec
DROP TABLE IF EXISTS search_cache;
CREATE TABLE search_cache (
query_key TEXT PRIMARY KEY, -- sha256(kind + "|" + query_norm)
query_raw TEXT NOT NULL,
query_norm TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('products', 'opportunities')),
results JSONB DEFAULT '[]'::jsonb,
sources JSONB DEFAULT '[]'::jsonb,
fetched_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
-- Indexes for performance
CREATE INDEX idx_search_cache_query_norm ON search_cache(query_norm);
CREATE INDEX idx_search_cache_expires_at ON search_cache(expires_at);
-- RLS Policy (Open for backend/service role, read-only for anon if needed, but mainly managed by function)
ALTER TABLE search_cache ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Enable read/write for service role" ON search_cache
USING (true)
WITH CHECK (true);
CREATE POLICY "Enable read for authenticated users" ON search_cache
FOR SELECT
TO authenticated
USING (true);

4
migration_n8n.sql Normal file
View file

@ -0,0 +1,4 @@
-- 9. N8N INTEGRATION
-- Add webhook URL to settings to offload sourcing logic
alter table public.settings
add column if not exists sourcing_webhook text;

110
n8n_workflow_sourcing.json Normal file
View file

@ -0,0 +1,110 @@
{
"name": "Search Arbitrage Workflow",
"nodes": [
{
"parameters": {
"path": "arbitrage_search",
"responseMode": "responseNode",
"options": {}
},
"id": "78a2b5c1-d4e5-4f6a-9b7c-8d9e0f1a2b3c",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1.1,
"position": [
460,
340
],
"webhookId": "78a2b5c1-d4e5-4f6a-9b7c-8d9e0f1a2b3c"
},
{
"parameters": {
"modelId": "models/gemini-1.5-pro",
"prompt": "={{ $json.query ? `OBJETIVO: Análise de Arbitragem Profissional para \"${$json.query}\".` : `OBJETIVO: Encontrar Oportunidades de Arbitragem (>25% Lucro) na categoria \"${$json.category}\".` }}\n\n1. BUSCA REAL OBRIGATÓRIA:\nVocê DEVE usar a tool googleSearch para buscar dados REAIS e ATUAIS no site www.comprasparaguai.com.br.\n{{ $json.query ? `Busque os 20 itens com o MENOR PREÇO ABSOLUTO para \"${$json.query}\".` : `Busque 20 produtos promissores na categoria \"${$json.category}\".` }}\nFoque em lojas de grande renome (Nissei, Cellshop, Atacado Games, Mega Eletronicos).\n\n2. BUSCA BRASIL (REFERÊNCIA):\nPara cada item, encontre o MENOR PREÇO de venda no Mercado Livre e Amazon Brasil.\n\n3. CÁLCULO DE MARGEM (Se for categoria):\n{{ $json.category ? `Para cada produto, estime o custo total em BRL (Preço PY * ${$json.exchangeRate || 5.75} * ${$json.useOverhead ? '1.20' : '1.00'}). Comparar com Venda BR. Retorne APENAS margem > 20%.` : '' }}\n\n4. RETORNO OBRIGATÓRIO:\nRetorne APENAS um JSON Array puro. NÃO use Markdown (```json). Schema:\n[\n {\n \"name\": \"Nome Produto\",\n \"priceUSD\": 100.00,\n \"priceBRL\": 575.00,\n \"store\": \"Nome Loja\",\n \"url\": \"Link URL\",\n \"marketPriceBRL\": 1200.00,\n \"amazonPrice\": 1250.00,\n \"mlPrice\": 1190.00\n }\n]",
"options": {
"googleSearch": true
}
},
"id": "1a2b3c4d-5e6f-7g8h-9i0j-1k2l3m4n5o6p",
"name": "Google Gemini",
"type": "n8n-nodes-base.googleGemini",
"typeVersion": 1,
"position": [
680,
340
],
"credentials": {
"googleGeminiApi": {
"id": "YOUR_CREDENTIAL_ID_HERE",
"name": "Google Gemini Account"
}
}
},
{
"parameters": {
"jsCode": "const text = $input.first().json.content || \"[]\";\nlet json = [];\ntry {\n const cleanText = text.replace(/```json/g, '').replace(/```/g, '').trim();\n json = JSON.parse(cleanText);\n} catch (e) {\n json = [];\n}\n\nreturn json.map(item => ({\n json: item\n}));"
},
"id": "2b3c4d5e-6f7g-8h9i-0j1k-2l3m4n5o6p7q",
"name": "Code",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
340
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "3c4d5e6f-7g8h-9i0j-1k2l-3m4n5o6p7q8r",
"name": "Respond to Webhook",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
1120,
340
]
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "Google Gemini",
"type": "main",
"index": 0
}
]
]
},
"Google Gemini": {
"main": [
[
{
"node": "Code",
"type": "main",
"index": 0
}
]
]
},
"Code": {
"main": [
[
{
"node": "Respond to Webhook",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {},
"staticData": null,
"pinData": {}
}

17
nginx.conf Normal file
View file

@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Optional: Cache control for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
}

View file

@ -1,314 +1,88 @@
import { GoogleGenAI, Type } from "@google/genai";
import { Product, AuctionLot, BiddingTender } from "../types";
import { supabase } from "./supabase";
// Vite: variáveis precisam começar com VITE_
const API_KEY = import.meta.env.VITE_GOOGLE_API_KEY as string | undefined;
if (!API_KEY) {
// Erro explícito pra não “falhar silencioso”
throw new Error(
"Missing VITE_GOOGLE_API_KEY. Add it to your .env.local (VITE_GOOGLE_API_KEY=...) and restart the dev server."
);
}
const ai = new GoogleGenAI({ apiKey: API_KEY });
// Removed API_KEY check for frontend as we now use Edge Functions
/**
* Helper: converte File em Part (inlineData) para o Gemini
* @deprecated Moved to Backend
*/
async function fileToPart(file: File) {
return new Promise<{ inlineData: { data: string; mimeType: string } }>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("Failed to read file"));
reader.onloadend = () => {
const result = reader.result as string | null;
if (!result) return reject(new Error("Empty file result"));
const base64Data = result.split(",")[1] || "";
resolve({
inlineData: {
data: base64Data,
mimeType: file.type || "application/octet-stream",
},
});
};
reader.readAsDataURL(file);
});
throw new Error("File processing must be moved to Backend Edge Functions.");
}
/**
* Sourcing Otimizado: Foco no MENOR PREÇO de compra (PY)
* e MENOR PREÇO de venda competitiva (BR - "Mais Vendidos")
* Sourcing Otimizado: Via Supabase Edge Function
*/
export async function searchProducts(
query: string
query: string,
currentExchangeRate?: number
): Promise<{ products: Product[]; sources: any[] }> {
// try { // Removed to propagate errors
const prompt = `OBJETIVO: Análise de Arbitragem Profissional.
try {
console.log("🚀 Calling Edge Function: search-products for:", query);
1. BUSCA REAL OBRIGATÓRIA:
Você DEVE usar a tool googleSearch para buscar dados REAIS e ATUAIS no site www.comprasparaguai.com.br.
Busque os 20 itens com o MENOR PREÇO ABSOLUTO para "${query}".
Foque em lojas de grande renome (Nissei, Cellshop, Atacado Games, Mega Eletronicos).
// Call Supabase Edge Function
const { data, error } = await supabase.functions.invoke('search-products', {
body: {
query,
kind: "products"
}
});
2. BUSCA BRASIL (REFERÊNCIA):
Para cada item, encontre o MENOR PREÇO de venda no Mercado Livre e Amazon Brasil.
if (error) throw error;
3. REGRAS CRÍTICAS (ANTI-ALUCINAÇÃO):
- JAMAIS invente produtos ou preços.
- JAMAIS retorne itens com "(Exemplo)", "(Example)" ou dados fictícios.
- Se não encontrar dados REAIS, retorne lista vazia.
- Use a cotação de dólar turismo/paralelo atual (~5.75 BRL/USD).
console.log(`📦 Edge Function Result: ${data.cache}`);
4. RETORNO:
Apenas JSON conforme o schema.`;
let products = data.products || [];
const sources = data.sources || [];
const response = await ai.models.generateContent({
model: "gemini-3-pro-preview", // Stable fast model
contents: prompt,
config: {
tools: [{ googleSearch: {} }],
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING, description: "Nome técnico do produto" },
priceUSD: { type: Type.NUMBER, description: "Menor preço em USD no Paraguai" },
priceBRL: { type: Type.NUMBER, description: "Preço USD convertido para BRL" },
store: { type: Type.STRING, description: "Loja do Paraguai (Nissei, Cellshop, etc)" },
url: { type: Type.STRING, description: "Link da oferta no Paraguai" },
marketPriceBRL: {
type: Type.NUMBER,
description: "Menor preço encontrado entre os MAIS VENDIDOS no Brasil",
},
amazonPrice: { type: Type.NUMBER, description: "Menor preço Amazon (Best Seller)" },
amazonUrl: { type: Type.STRING },
mlPrice: { type: Type.NUMBER, description: "Menor preço Mercado Livre (Mais Vendido)" },
mlUrl: { type: Type.STRING },
},
required: ["name", "priceUSD", "priceBRL", "store", "url", "marketPriceBRL"],
},
},
},
});
// Recalculate BRL if exchange rate provided
if (currentExchangeRate && Array.isArray(products)) {
products = products.map((p: any) => ({
...p,
priceBRL: p.priceUSD * currentExchangeRate
}));
}
const text = response.text || "[]";
const products = JSON.parse(text);
const sources = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
return { products, sources };
// Ordena para mostrar primeiro as melhores oportunidades (menor custo em BRL)
const sortedProducts = Array.isArray(products)
? products.sort((a: any, b: any) => (a.priceBRL ?? 0) - (b.priceBRL ?? 0))
: [];
return { products: sortedProducts as Product[], sources };
// } catch (error) {
// console.error("Erro na busca de arbitrage:", error);
// return { products: [], sources: [] };
// }
} catch (error) {
console.error("Erro na busca de produtos (Edge Function):", error);
throw error;
}
}
/**
* Busca Oportunidades (>25% Margem) por Categoria
* @todo Migrate to Edge Function
*/
export async function searchOpportunities(category: string, includeOverhead: boolean = true): Promise<{ products: Product[]; sources: any[] }> {
// try {
const prompt = `OBJETIVO: Encontrar Oportunidades de Arbitragem (>25% Lucro) na categoria "${category}".
1. BUSCA PARAGUAI:
Vasculhe as principais lojas do Paraguai (Nissei, Cellshop, Mega, Atacado Games) em busca de produtos da categoria "${category}" que estejam com preços muito atrativos ou em oferta.
Identifique pelo menos 20 produtos promissores.
2. CÁLCULO DE MARGEM:
Para cada produto, estime o custo total em BRL (Preço PY * 5.75 * ${includeOverhead ? '1.20' : '1.00'} de taxas).
Compare com o MENOR preço de venda real no Brasil (Mercado Livre/Amazon - Filtro "Mais Vendidos").
Margem = (Preço Venda BR - Custo Total) / Preço Venda BR.
3. FILTRO RIGOROSO:
Retorne APENAS produtos onde a Margem estimada seja SUPERIOR a 20-25%.
Se não encontrar nada com 25%, mostre os que tiverem a maior margem possível.
4. RETORNO:
Apenas JSON conforme o schema.`;
const response = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: prompt,
config: {
tools: [{ googleSearch: {} }],
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
name: { type: Type.STRING, description: "Nome técnico do produto" },
priceUSD: { type: Type.NUMBER, description: "Menor preço em USD no Paraguai" },
priceBRL: { type: Type.NUMBER, description: "Preço USD convertido para BRL" },
store: { type: Type.STRING, description: "Loja do Paraguai (Nissei, Cellshop, etc)" },
url: { type: Type.STRING, description: "Link da oferta no Paraguai" },
marketPriceBRL: {
type: Type.NUMBER,
description: "Menor preço encontrado entre os MAIS VENDIDOS no Brasil",
},
amazonPrice: { type: Type.NUMBER, description: "Menor preço Amazon (Best Seller)" },
amazonUrl: { type: Type.STRING },
mlPrice: { type: Type.NUMBER, description: "Menor preço Mercado Livre (Mais Vendido)" },
mlUrl: { type: Type.STRING },
},
required: ["name", "priceUSD", "priceBRL", "store", "url", "marketPriceBRL"],
},
},
},
});
const text = response.text || "[]";
const products = JSON.parse(text);
const sources = response.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
// Ordena por maior margem (diferença entre venda BR e custo PY)
const sortedProducts = Array.isArray(products)
? products.sort((a: any, b: any) => {
const marginA = (a.marketPriceBRL - a.priceBRL) / a.marketPriceBRL;
const marginB = (b.marketPriceBRL - b.priceBRL) / b.marketPriceBRL;
return marginB - marginA; // Maior margem primeiro
})
: [];
return { products: sortedProducts as Product[], sources };
// } catch (error) {
// console.error("Erro na busca de oportunidades:", error);
// return { products: [], sources: [] };
// }
export async function searchOpportunities(
category: string,
includeOverhead: boolean = true,
currentExchangeRate?: number
): Promise<{ products: Product[]; sources: any[] }> {
console.warn("searchOpportunities not yet migrated to Edge Function.");
return { products: [], sources: [] };
}
/**
* Leilões: Analisa texto, link ou ARQUIVO PDF
* @todo Migrate to Edge Function
*/
export async function analyzeAuctionData(
input: string | File
): Promise<{ lot: AuctionLot | null; sources: any[] }> {
try {
const promptPrefix = "Analise este lote de leilão da Receita Federal.";
const parts: any[] = [];
if (input instanceof File) {
const filePart = await fileToPart(input);
parts.push(filePart);
parts.push({
text: `${promptPrefix}
Leia o documento PDF anexo e extraia TODOS os itens da tabela de mercadorias.
REGRAS:
1. Identifique os itens reais.
2. Descubra o MENOR valor real de mercado no Brasil (baseado nos itens mais vendidos) via Google Search.
3. Use URLs REAIS dos sites de busca.`,
});
} else {
parts.push({
text: `${promptPrefix}
Analise este conteúdo: "${input}".
REGRAS:
1. Identifique os itens reais.
2. Descubra o MENOR valor real de mercado no Brasil (baseado nos itens mais vendidos) via Google Search.
3. Use URLs REAIS dos sites de busca.`,
});
}
const analysisResponse = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: { parts }, // padronizado
config: { tools: [{ googleSearch: {} }] },
});
const analysisText = analysisResponse.text || "";
const sources = analysisResponse.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
// Segunda passada: força JSON limpo
const extraction = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: `Transforme em JSON conforme o schema. Texto base: ${analysisText}`,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING },
title: { type: Type.STRING },
location: { type: Type.STRING },
items: { type: Type.ARRAY, items: { type: Type.STRING } },
currentBid: { type: Type.NUMBER },
minBid: { type: Type.NUMBER },
mlMarketPrice: { type: Type.NUMBER },
},
required: ["id", "title", "items", "minBid", "mlMarketPrice"],
},
},
});
const lotData = JSON.parse(extraction.text || "{}");
return {
lot: {
...lotData,
status: "Analyzing" as any,
url: typeof input === "string" && input.startsWith("http") ? input : "",
},
sources,
};
} catch (error) {
console.error("Erro ao analisar leilão:", error);
return { lot: null, sources: [] };
}
console.warn("analyzeAuctionData not yet migrated to Edge Function.");
return { lot: null, sources: [] };
}
/**
* Licitações Otimizadas
* @todo Migrate to Edge Function
*/
export async function searchBiddings(
query: string
): Promise<{ tenders: BiddingTender[]; sources: any[] }> {
try {
const prompt = `Pesquise licitações ativas para "${query}" no Brasil.
Priorize PNCP e Compras.gov.br.
Retorne apenas JSON conforme o schema.`;
const searchResponse = await ai.models.generateContent({
model: "gemini-3-pro-preview",
contents: prompt,
config: {
tools: [{ googleSearch: {} }],
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
id: { type: Type.STRING },
org: { type: Type.STRING },
object: { type: Type.STRING },
estimatedValue: { type: Type.NUMBER },
deadline: { type: Type.STRING },
items: { type: Type.ARRAY, items: { type: Type.STRING } },
requirements: { type: Type.ARRAY, items: { type: Type.STRING } },
marketReferencePrice: { type: Type.NUMBER },
url: { type: Type.STRING },
},
required: ["id", "org", "object", "url"],
},
},
},
});
const tenders = JSON.parse(searchResponse.text || "[]");
const sources = searchResponse.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
return { tenders: (Array.isArray(tenders) ? tenders : []) as BiddingTender[], sources };
} catch (error) {
console.error("Erro na busca de licitações:", error);
return { tenders: [], sources: [] };
}
console.warn("searchBiddings not yet migrated to Edge Function.");
return { tenders: [], sources: [] };
}

View file

@ -0,0 +1,229 @@
import { createClient } from "npm:@supabase/supabase-js@2";
import { GoogleGenerativeAI, SchemaType } from "npm:@google/generative-ai";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const GOOGLE_API_KEY = Deno.env.get("GOOGLE_API_KEY") ?? "";
const FX_BRL_PER_USD_ENV = Deno.env.get("FX_BRL_PER_USD");
if (!SUPABASE_URL) throw new Error("Missing SUPABASE_URL");
if (!SUPABASE_SERVICE_ROLE_KEY) throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
if (!GOOGLE_API_KEY) throw new Error("Missing GOOGLE_API_KEY");
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
// Use stable SDK class
const genAI = new GoogleGenerativeAI(GOOGLE_API_KEY);
function normalizeQuery(q: string) {
return q
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
async function sha256Hex(input: string) {
const enc = new TextEncoder();
const hashBuffer = await crypto.subtle.digest("SHA-256", enc.encode(input));
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function getFxRate(): Promise<number> {
// 1) ENV
const envFx = FX_BRL_PER_USD_ENV ? Number(FX_BRL_PER_USD_ENV) : NaN;
if (Number.isFinite(envFx) && envFx > 0) return envFx;
// 2) settings table
try {
const { data } = await supabase.from("settings").select("*").limit(1).maybeSingle();
const fx =
Number((data as any)?.usd_brl_rate) ||
Number((data as any)?.dolar_brl) ||
Number((data as any)?.usd_brl) ||
Number((data as any)?.default_exchange) ||
NaN;
if (Number.isFinite(fx) && fx > 0) return fx;
} catch {
// ignore
}
// 3) fallback
return 5.75;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const body = await req.json().catch(() => ({}));
const query = typeof body.query === "string" ? body.query : "";
const kind =
typeof body.kind === "string" && body.kind.trim() ? body.kind.trim() : "products";
if (!query) throw new Error("Missing query");
const query_norm = normalizeQuery(query);
const query_key = await sha256Hex(`${kind}|${query_norm}`);
// CACHE HIT
const { data: cached, error: cacheErr } = await supabase
.from("search_cache")
.select("results,sources,expires_at,fetched_at")
.eq("query_key", query_key)
.maybeSingle();
if (cacheErr) console.error("cacheErr:", cacheErr);
if (cached?.expires_at && new Date(cached.expires_at) > new Date()) {
console.log("CACHE HIT:", query);
return new Response(
JSON.stringify({
cache: "HIT",
fetched_at: cached.fetched_at,
expires_at: cached.expires_at,
products: cached.results ?? [],
sources: cached.sources ?? [],
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// PY-ONLY PROMPT
const prompt = `OBJETIVO: Sourcing no Paraguai (rápido e real).
1) BUSCA REAL OBRIGATÓRIA:
Você DEVE usar a tool googleSearch.
2) FONTE ÚNICA:
Buscar dados REAIS e ATUAIS SOMENTE no site:
www.comprasparaguai.com.br
3) RESULTADO:
Retorne os 10 itens com o MENOR PREÇO ABSOLUTO para: "${query}"
4) PRIORIDADE DE LOJAS:
preferência a lojas conhecidas (quando existirem nos resultados):
Nissei, Cellshop, Mega Eletronicos, Atacado Games.
5) REGRAS CRÍTICAS (ANTI-ALUCINAÇÃO):
- JAMAIS invente produtos, preços, lojas ou links.
- Se não encontrar dados REAIS, retorne [].
- Não inclua exemplos, placeholders ou valores estimados.
6) RETORNO:
Apenas JSON conforme o schema. NÃO retorne texto fora do JSON.
IMPORTANTE: Nesta etapa, NÃO busque preços no Brasil (Mercado Livre/Amazon).`;
// Initialize Model (Stable SDK)
const model = genAI.getGenerativeModel({
model: "gemini-1.5-pro",
tools: [{ googleSearch: {} }]
});
const geminiResp = await model.generateContent({
contents: [{ role: "user", parts: [{ text: prompt }] }],
generationConfig: {
responseMimeType: "application/json",
responseSchema: {
type: SchemaType.ARRAY,
items: {
type: SchemaType.OBJECT,
properties: {
name: { type: SchemaType.STRING },
priceUSD: { type: SchemaType.NUMBER },
priceBRL: { type: SchemaType.NUMBER },
store: { type: SchemaType.STRING },
url: { type: SchemaType.STRING },
},
required: ["name", "priceUSD", "store", "url"],
},
},
},
});
const geminiResult = geminiResp.response;
const raw = geminiResult.text();
let products: any[] = [];
try {
products = JSON.parse(raw);
if (!Array.isArray(products)) products = [];
} catch {
products = [];
}
const sources = geminiResult.candidates?.[0]?.groundingMetadata?.groundingChunks || [];
// Completa priceBRL
const fx = await getFxRate();
products = products.map((p) => {
const priceUSD = typeof p?.priceUSD === "number" ? p.priceUSD : null;
const priceBRL =
typeof p?.priceBRL === "number"
? p.priceBRL
: priceUSD !== null
? Number((priceUSD * fx).toFixed(2))
: null;
return { ...p, priceBRL };
});
// Ordena
products.sort((a, b) => (a.priceUSD ?? 0) - (b.priceUSD ?? 0));
// CACHE SAVE
const fetched_at = new Date().toISOString();
const expires_at = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString();
const { error: upsertErr } = await supabase.from("search_cache").upsert({
query_key,
query_raw: query,
query_norm,
kind,
results: products,
sources,
fetched_at,
expires_at,
});
if (upsertErr) console.error("upsertErr:", upsertErr);
return new Response(
JSON.stringify({
cache: "MISS",
fetched_at,
expires_at,
fx,
products,
sources,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Function Error:", error);
return new Response(JSON.stringify({ error: String(error?.message ?? error) }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});