chore: add docker deployment configuration
This commit is contained in:
parent
de9bf86d94
commit
ff07aecb45
12 changed files with 787 additions and 372 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
3
App.tsx
3
App.tsx
|
|
@ -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
23
Dockerfile
Normal 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;"]
|
||||
|
|
@ -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 só
|
||||
consomem busca quando você clica no marketplace (economiza recursos).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
28
migration_cache.sql
Normal 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
29
migration_edge_cache.sql
Normal 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
4
migration_n8n.sql
Normal 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
110
n8n_workflow_sourcing.json
Normal 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
17
nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [] };
|
||||
}
|
||||
|
|
|
|||
229
supabase/functions/search-products/index.ts
Normal file
229
supabase/functions/search-products/index.ts
Normal 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:
|
||||
Dê 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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue