From a9954247250873df6dc1184bf674cc7104171401 Mon Sep 17 00:00:00 2001 From: Marcio Bevervanso Date: Tue, 16 Jun 2026 09:27:58 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20otimizar=20app=20para=20campanhas=20de?= =?UTF-8?q?=20convers=C3=A3o=20low-ticket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refina o pipeline de criação de campanhas para produtos de entrada (ticket baixo, Pix/cartão) com foco em otimização por compra e controle de custo, evitando prejuízo. - scraper: lê sites SPA/JS (og tags, JSON-LD com preço/oferta, fallback de renderização via r.jina.ai) — antes só lia o - skins: nova skin "Festa Infantil / Produto de Entrada" com copy-rules de tripwire (Pix = acesso imediato, preço-âncora) e default em Vendas - create: campanha de Vendas exige Pixel/Dataset e otimiza por OFFSITE_CONVERSIONS (antes caía para LINK_CLICKS), evento de conversão selecionável e estratégia Cost Cap (teto de custo por compra) - corrige bid_strategy pré-mapeado errado no cliente que anulava o Bid Cap - settings: persiste Pixel/Dataset ID e pré-preenche no formulário - copy/analyzer: usam oferta, preço e formas de pagamento; variações A/B (preço / benefício / dor) para resposta direta - creative: opção de estampar preço/oferta no criativo Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- src/app/api/ai/generate-copy/route.ts | 30 +++- src/app/api/ai/generate-image/route.ts | 3 +- src/app/api/campaigns/create/route.ts | 49 +++++- src/app/api/settings/route.ts | 1 + src/app/dashboard/create/page.tsx | 157 ++++++++++++++---- src/app/dashboard/settings/page.tsx | 22 ++- src/config/skins/index.ts | 33 ++++ src/lib/ai/analyzer.ts | 8 +- src/lib/ai/copywriter.ts | 44 ++++- src/lib/ai/creative.ts | 10 +- src/lib/ai/scraper.ts | 220 +++++++++++++++++++++---- src/lib/settings.ts | 4 + 12 files changed, 497 insertions(+), 84 deletions(-) diff --git a/src/app/api/ai/generate-copy/route.ts b/src/app/api/ai/generate-copy/route.ts index 93f3ae8..9e5d2dd 100644 --- a/src/app/api/ai/generate-copy/route.ts +++ b/src/app/api/ai/generate-copy/route.ts @@ -4,7 +4,20 @@ import { generateAdCopy } from '@/lib/ai/copywriter'; export async function POST(request: NextRequest) { try { const body = await request.json(); - const { context, copy_rules, objective, tone, count, category } = body; + const { + context, + copy_rules, + objective, + tone, + count, + category, + link, + offer, + price, + payment_methods, + guarantee, + urgency, + } = body; if (!context || !objective) { return NextResponse.json( @@ -13,7 +26,20 @@ export async function POST(request: NextRequest) { ); } - const variations = await generateAdCopy({ context, copy_rules, objective, tone, count, category }); + const variations = await generateAdCopy({ + context, + copy_rules, + objective, + tone, + count, + category, + link, + offer, + price, + payment_methods, + guarantee, + urgency, + }); return NextResponse.json({ success: true, data: variations }); } catch (error) { diff --git a/src/app/api/ai/generate-image/route.ts b/src/app/api/ai/generate-image/route.ts index 98ee044..7294611 100644 --- a/src/app/api/ai/generate-image/route.ts +++ b/src/app/api/ai/generate-image/route.ts @@ -4,7 +4,7 @@ import { generateAdImage } from '@/lib/ai/creative'; export async function POST(request: NextRequest) { try { const body = await request.json(); - const { context, style, dimensions, mood } = body; + const { context, style, dimensions, mood, offer_text } = body; if (!context || !style) { return NextResponse.json( @@ -25,6 +25,7 @@ export async function POST(request: NextRequest) { style, dimensions: dim, mood, + offer_text, }) ); diff --git a/src/app/api/campaigns/create/route.ts b/src/app/api/campaigns/create/route.ts index c81f9c9..3f23f21 100644 --- a/src/app/api/campaigns/create/route.ts +++ b/src/app/api/campaigns/create/route.ts @@ -188,24 +188,57 @@ export async function POST(req: Request) { ); } - if (bid_strategy === 'BID_CAP') { + if (bid_strategy === 'BID_CAP' || bid_strategy === 'COST_CAP') { if (!finalBidAmount || finalBidAmount <= 0) { + const label = bid_strategy === 'COST_CAP' ? 'Custo por Resultado (Cost Cap)' : 'Bid Cap'; return NextResponse.json( - { error: 'Você selecionou a estratégia de "Bid Cap", mas não informou o valor do lance (Bid). Por favor, volte e defina o valor do Bid Cap ou mude para "Volume Máximo".' }, + { error: `Você selecionou a estratégia de "${label}", mas não informou o valor. Volte e defina o teto em reais ou mude para "Volume Máximo".` }, { status: 400 } ); } } - const { pixel_id } = body; + + const { pixel_id, conversion_event } = body; + + // Estratégia de lance: + // - BID_CAP → LOWEST_COST_WITH_BID_CAP (teto de lance no leilão) + // - COST_CAP → COST_CAP (teto de custo POR resultado/compra — ideal p/ não dar + // prejuízo em produto de entrada: você define o máximo que aceita pagar por compra) + // - default → LOWEST_COST_WITHOUT_CAP (volume máximo) + const finalBidStrategy = + bid_strategy === 'BID_CAP' + ? 'LOWEST_COST_WITH_BID_CAP' + : bid_strategy === 'COST_CAP' + ? 'COST_CAP' + : 'LOWEST_COST_WITHOUT_CAP'; let finalOptimizationGoal = optimization_goal; let finalPromotedObject = promoted_object; - if (finalObjective === 'OUTCOME_SALES' && pixel_id) { + // Otimização por COMPRA: só funciona com Pixel/Dataset (CAPI). Sem isso, o + // Meta otimizaria por clique e o low-ticket sangra — então exigimos o pixel + // quando o objetivo é Vendas. + if (finalObjective === 'OUTCOME_SALES') { + if (!pixel_id) { + return NextResponse.json( + { + success: false, + error: + 'Para campanhas de Vendas (otimização por compra) é obrigatório informar o Pixel/Dataset ID. Configure em Configurações ou no formulário. Sem ele, o Meta otimiza por clique e o produto de entrada tende a dar prejuízo.', + }, + { status: 400 } + ); + } + + const validEvents = ['PURCHASE', 'INITIATE_CHECKOUT', 'ADD_PAYMENT_INFO', 'ADD_TO_CART', 'LEAD']; + const eventType = validEvents.includes(String(conversion_event)) + ? conversion_event + : 'PURCHASE'; + finalOptimizationGoal = 'OFFSITE_CONVERSIONS'; finalPromotedObject = { - pixel_id: pixel_id, - custom_event_type: 'PURCHASE', + pixel_id, + custom_event_type: eventType, }; } @@ -213,8 +246,8 @@ export async function POST(req: Request) { campaign_name, objective: finalObjective, daily_budget: finalDailyBudget, - bid_strategy: bid_strategy === 'BID_CAP' ? 'LOWEST_COST_WITH_BID_CAP' : 'LOWEST_COST_WITHOUT_CAP', - bid_amount: bid_amount, + bid_strategy: finalBidStrategy, + bid_amount: finalBidStrategy === 'LOWEST_COST_WITHOUT_CAP' ? undefined : finalBidAmount, targeting: { ...(targeting || { geo_locations: { diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index ab64288..386b98d 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -7,6 +7,7 @@ export async function GET() { // Mask secrets so we don't send them back to the client return NextResponse.json({ metaAppId: settings.metaAppId, + metaPixelId: settings.metaPixelId, hasMetaSecret: !!settings.metaAppSecret, hasOpenaiKey: !!settings.openaiApiKey, }); diff --git a/src/app/dashboard/create/page.tsx b/src/app/dashboard/create/page.tsx index d5cbc64..28de62c 100644 --- a/src/app/dashboard/create/page.tsx +++ b/src/app/dashboard/create/page.tsx @@ -51,6 +51,7 @@ export default function CreateCampaignPage() { bid_amount: 10, link: '', pixel_id: '', + conversion_event: 'PURCHASE', image_style: currentSkin.ai_guidelines.image_style, }); @@ -70,6 +71,9 @@ export default function CreateCampaignPage() { const [imageSource, setImageSource] = useState<ImageSource>('ai'); + // Estampar o preço/oferta no criativo gerado por IA (bom p/ low-ticket DR). + const [stampOffer, setStampOffer] = useState(false); + const [uploadedImageBytes, setUploadedImageBytes] = useState<string | null>(null); @@ -128,6 +132,26 @@ export default function CreateCampaignPage() { }; }, []); + // Pré-preenche o Pixel/Dataset ID salvo em Configurações — para low-ticket, + // otimizar por COMPRA depende dele, então evitamos que o usuário esqueça. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch('/api/settings'); + const data = await res.json(); + if (!cancelled && data?.metaPixelId) { + setForm((prev) => (prev.pixel_id ? prev : { ...prev, pixel_id: data.metaPixelId })); + } + } catch { + // Sem bloqueio — o usuário pode digitar o Pixel manualmente. + } + })(); + return () => { + cancelled = true; + }; + }, []); + // Saves/updates the campaign draft in Supabase so generated copy and images // survive errors during publish (e.g. Meta API failures) without needing // to be regenerated from scratch. @@ -352,6 +376,13 @@ export default function CreateCampaignPage() { objective: form.objective, category: skinId, count: 3, + link: form.link, + // Pistas de oferta para copy de resposta direta (low-ticket). + // Os ids batem com os campos da skin "festa_infantil". + offer: customFields['offer'], + payment_methods: customFields['payment'], + guarantee: customFields['bonus'], + urgency: customFields['bonus'], }), } ); @@ -383,6 +414,9 @@ export default function CreateCampaignPage() { body: JSON.stringify({ context: getContextString(), style: form.image_style, + offer_text: stampOffer + ? customFields['offer'] || customFields['product_name'] + : undefined, }), } ); @@ -483,18 +517,14 @@ export default function CreateCampaignPage() { form.daily_budget * 100 ), - bid_strategy: - form.bid_strategy === - 'BID_CAP' - ? 'LOWEST_COST_WITH_BID_CAP' - : undefined, + // Envia o token cru ('VOLUME' | 'COST_CAP' | 'BID_CAP'); a rota + // converte para o enum da Meta e valida o bid_amount. + bid_strategy: form.bid_strategy, bid_amount: - form.bid_strategy === - 'BID_CAP' - ? Math.round( - form.bid_amount * 100 - ) + form.bid_strategy === 'BID_CAP' || + form.bid_strategy === 'COST_CAP' + ? Math.round(form.bid_amount * 100) : undefined, targeting: @@ -510,7 +540,9 @@ export default function CreateCampaignPage() { instagram_actor_id: selectedInstagramAccount?.id || undefined, - pixel_id: form.pixel_id, + pixel_id: form.pixel_id || undefined, + + conversion_event: form.conversion_event, image_urls: imageSource === 'ai' @@ -1076,6 +1108,10 @@ export default function CreateCampaignPage() { Volume Máximo </option> + <option value="COST_CAP"> + Custo por Compra (Cost Cap) + </option> + <option value="BID_CAP"> Bid Cap </option> @@ -1083,11 +1119,13 @@ export default function CreateCampaignPage() { </div> {/* BID */} - {form.bid_strategy === - 'BID_CAP' && ( + {(form.bid_strategy === 'BID_CAP' || + form.bid_strategy === 'COST_CAP') && ( <div> <label className="block text-xs uppercase tracking-wider font-medium mb-2 text-green-400"> - Bid Cap (R$) + {form.bid_strategy === 'COST_CAP' + ? 'Custo máx. por compra (R$)' + : 'Bid Cap (R$)'} </label> <input @@ -1111,28 +1149,70 @@ export default function CreateCampaignPage() { }) } /> + {form.bid_strategy === 'COST_CAP' && ( + <p className="text-[11px] mt-1.5 text-white/50 leading-relaxed"> + Teto que você aceita pagar <strong>por compra</strong>. O Meta tenta nunca passar disso — ideal pra produto de entrada não dar prejuízo. Comece com um valor próximo ao seu lucro com o funil (order bump/upsell), não só o ticket de entrada. + </p> + )} </div> )} </div> - {/* PIXEL ID */} + {/* PIXEL + CONVERSION EVENT (otimização por compra) */} {form.objective === 'OUTCOME_SALES' && ( - <div> - <label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400"> - ID do Pixel (Recomendado para Vendas) - </label> + <div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-4 space-y-4"> + <div> + <h3 className="text-sm font-semibold text-white mb-1"> + Otimização por Compra (obrigatório para Vendas) + </h3> + <p className="text-[11px] text-white/50 leading-relaxed"> + O Meta vai aprender a entregar para quem <strong>compra</strong>, não para quem só clica. Sem o Pixel/Dataset, o produto de entrada tende a dar prejuízo. + </p> + </div> - <input - className="input-field" - value={form.pixel_id} - onChange={(e) => - setForm({ - ...form, - pixel_id: e.target.value, - }) - } - placeholder="Ex: 1494357542144348" - /> + <div> + <label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400"> + Pixel / Dataset ID * + </label> + <input + className="input-field" + value={form.pixel_id} + onChange={(e) => + setForm({ + ...form, + pixel_id: e.target.value, + }) + } + placeholder="Ex: 1494357542144348" + /> + <p className="text-[11px] mt-1.5 text-white/40"> + Como você envia a Compra via n8n/CAPI, use o mesmo Dataset ID que recebe o evento. Salve em Configurações para preencher automaticamente. + </p> + </div> + + <div> + <label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400"> + Evento de conversão para otimizar + </label> + <select + className="input-field" + value={form.conversion_event} + onChange={(e) => + setForm({ + ...form, + conversion_event: e.target.value, + }) + } + > + <option value="PURCHASE">Compra (Purchase) — ideal quando há volume</option> + <option value="INITIATE_CHECKOUT">Início de checkout — bom p/ sair da fase de aprendizado</option> + <option value="ADD_PAYMENT_INFO">Adicionou pagamento</option> + <option value="ADD_TO_CART">Adicionou ao carrinho</option> + </select> + <p className="text-[11px] mt-1.5 text-white/40 leading-relaxed"> + Com ticket baixo e orçamento pequeno, talvez não saiam ~50 compras/semana (mínimo pro Meta otimizar bem). Nesse caso, comece otimizando por <strong>Início de checkout</strong> e migre para <strong>Compra</strong> quando o volume crescer. + </p> + </div> </div> )} @@ -1245,6 +1325,23 @@ export default function CreateCampaignPage() { </option> </select> )} + + {imageSource === 'ai' && ( + <label className="mt-3 flex items-start gap-2.5 cursor-pointer select-none"> + <input + type="checkbox" + checked={stampOffer} + onChange={(e) => setStampOffer(e.target.checked)} + className="mt-0.5 h-4 w-4 accent-[var(--accent)]" + /> + <span className="text-xs text-white/60 leading-relaxed"> + Estampar o preço/oferta no criativo + <span className="block text-white/40"> + Recomendado para produto de entrada — preço visível costuma elevar o CTR. Usa o campo "Oferta + Preço". + </span> + </span> + </label> + )} </div> {/* BUTTON */} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 8a467b7..64d1aac 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -11,6 +11,7 @@ export default function SettingsPage() { const [metaAppId, setMetaAppId] = useState(''); const [metaAppSecret, setMetaAppSecret] = useState(''); + const [metaPixelId, setMetaPixelId] = useState(''); const [openaiKey, setOpenaiKey] = useState(''); const [hasMetaSecret, setHasMetaSecret] = useState(false); @@ -21,6 +22,7 @@ export default function SettingsPage() { .then(res => res.json()) .then(data => { setMetaAppId(data.metaAppId || ''); + setMetaPixelId(data.metaPixelId || ''); setHasMetaSecret(data.hasMetaSecret); setHasOpenaiKey(data.hasOpenaiKey); setLoading(false); @@ -36,8 +38,8 @@ export default function SettingsPage() { setSaving(true); setMessage(null); - const payload: Record<string, string> = { metaAppId }; - + const payload: Record<string, string> = { metaAppId, metaPixelId }; + // Only send secrets if they were changed if (metaAppSecret) payload.metaAppSecret = metaAppSecret; if (openaiKey) payload.openaiApiKey = openaiKey; @@ -137,6 +139,22 @@ export default function SettingsPage() { </p> </div> + <div> + <label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}> + Pixel / Dataset ID + </label> + <input + type="text" + value={metaPixelId} + onChange={e => setMetaPixelId(e.target.value)} + className="input-field" + placeholder="Ex: 1494357542144348" + /> + <p className="text-[11px] mt-1.5" style={{ color: 'var(--muted-fg)' }}> + Usado para otimizar campanhas por <strong>Compra</strong> (Conversions API). Preenche automaticamente o formulário de criação. Use o mesmo Dataset ID que recebe o evento de Compra do seu n8n/CAPI. + </p> + </div> + <div className="mt-4 p-3 rounded-lg flex items-start gap-3" style={{ background: 'rgba(59, 130, 246, 0.1)' }}> <ShieldAlert size={16} className="text-blue-400 mt-0.5 shrink-0" /> <div className="text-[11px] text-blue-200"> diff --git a/src/config/skins/index.ts b/src/config/skins/index.ts index 062a221..1fcd587 100644 --- a/src/config/skins/index.ts +++ b/src/config/skins/index.ts @@ -200,6 +200,39 @@ export const SYSTEM_SKINS: SkinConfig[] = [ targeting_template: 'broad_brazil', bid_strategy: 'VOLUME', } + }, + { + id: 'festa_infantil', + name: 'Festa Infantil / Produto de Entrada', + icon: '🎉', + themeColor: 'bg-pink-900/40 border-pink-500/50', + accentColor: '#ec4899', + fields: [ + { id: 'product_name', label: 'O que você vende? (produto de entrada)', placeholder: 'Ex: Kit Festa Mágica IA — convites, painéis e lembrancinhas personalizados', type: 'text', required: true }, + { id: 'offer', label: 'Oferta + Preço (o gancho do anúncio)', placeholder: 'Ex: Kit completo por apenas R$9,99 no Pix — acesso imediato', type: 'text', required: true }, + { id: 'payment', label: 'Formas de pagamento / vantagem', placeholder: 'Ex: Pix (libera na hora) ou cartão em até 12x', type: 'text' }, + { id: 'whats_included', label: 'O que está incluso / transformação', placeholder: 'Ex: convite animado, painel, topo de bolo, lembrancinhas — tudo editável e pronto em minutos', type: 'textarea', required: true }, + { id: 'pain_point', label: 'Dor / desejo do cliente', placeholder: 'Ex: quer uma festa linda sem gastar fortuna nem perder dias procurando decoração', type: 'textarea' }, + { id: 'bonus', label: 'Bônus / garantia / urgência (opcional)', placeholder: 'Ex: + 3 temas de bônus hoje · garantia de 7 dias · oferta de lançamento', type: 'text' }, + ], + ai_guidelines: { + // Copy de resposta direta para tripwire/low-ticket: o objetivo é a COMPRA + // de entrada barata, com o lucro vindo do funil (order bump/upsell). + copy_rules: + 'Você escreve para um PRODUTO DE ENTRADA de ticket baixo (tripwire) voltado a pais e mães organizando festas infantis. Objetivo: gerar a COMPRA imediata, não cliques curiosos. ' + + 'Lidere pelo preço-âncora e pela facilidade ("R$X no Pix e recebe na hora"). Destaque Pix = acesso imediato e cartão parcelado quando houver. ' + + 'Use o contraste de valor (o que receberia vs. quanto custa), prova de praticidade ("pronto em minutos"), e uma urgência honesta (oferta de lançamento/bônus). ' + + 'Fale a língua de pai/mãe: economia de tempo, festa encantadora, criança feliz, sem dor de cabeça. ' + + 'Evite promessas impossíveis e clickbait. CTA de compra (SHOP_NOW ou GET_OFFER). Primary text curto, escaneável e específico — sem encher linguiça.', + image_style: + 'Festa infantil mágica e encantadora, cores vibrantes e alegres, elementos lúdicos (balões, estrelas, brilho), tema de aniversário, visual limpo com destaque para o produto/oferta, alto contraste para parar o feed', + }, + meta_defaults: { + // Low-ticket vive ou morre por otimização de COMPRA via Pixel/CAPI. + objective: 'OUTCOME_SALES', + targeting_template: 'parents_kids', + bid_strategy: 'VOLUME', + } } ]; diff --git a/src/lib/ai/analyzer.ts b/src/lib/ai/analyzer.ts index 66ef64e..c22ab41 100644 --- a/src/lib/ai/analyzer.ts +++ b/src/lib/ai/analyzer.ts @@ -73,14 +73,14 @@ ${JSON.stringify(skinsCatalog, null, 2)} OBJETIVOS VÁLIDOS NO META ADS: ${VALID_OBJECTIVES.join(', ')} SUA TAREFA: -1. Identifique o "skin_id" mais adequado dentre os disponíveis. -2. Para CADA campo definido nesse skin (array "fields"), gere um valor coerente e específico baseado no conteúdo real do site (nunca deixe vazio; se a informação não existir explicitamente, infira de forma plausível a partir do contexto). +1. Identifique o "skin_id" mais adequado dentre os disponíveis. Se for um produto vendido diretamente com preço/checkout (especialmente ticket baixo, como kits, e-books, templates, "produto de entrada"), prefira o skin "festa_infantil" quando for festa/aniversário infantil, ou o mais próximo (ecommerce/infoproduct). +2. Para CADA campo definido nesse skin (array "fields"), gere um valor coerente e específico baseado no conteúdo real do site (nunca deixe vazio; se a informação não existir explicitamente, infira de forma plausível a partir do contexto). IMPORTANTE: extraia o PREÇO e as FORMAS DE PAGAMENTO (Pix, cartão, parcelamento) se aparecerem no conteúdo, e use-os nos campos de oferta/pagamento. 3. Sugira um "campaign_name" curto e profissional (ex: "Nome da Marca — Campanha [mês/ano ou ângulo]"). 4. Descreva o público-alvo ideal em uma frase curta e direcionada (máx. 12 palavras). -5. Escolha o "objective" mais estratégico dentre os válidos para esse tipo de negócio. +5. Escolha o "objective" mais estratégico dentre os válidos. Se o site vende um produto diretamente (tem preço/checkout/compra), use "OUTCOME_SALES" para otimizar por compra. Use leads/tráfego só quando não houver venda direta. 6. Defina o "tone" (tom de voz ideal para a copy, ex: "consultivo e confiável", "urgente e persuasivo"). 7. Refine o "image_style" do skin escolhido com detalhes específicos do negócio analisado (cores, ambientação, elementos visuais que conversam com a marca). -8. Escreva um "summary" de 1-2 frases explicando por que você direcionou a campanha dessa forma. +8. Escreva um "summary" de 1-2 frases explicando por que você direcionou a campanha dessa forma (mencione preço/pagamento se for um produto de entrada). Retorne APENAS um JSON com esta estrutura exata: { diff --git a/src/lib/ai/copywriter.ts b/src/lib/ai/copywriter.ts index 1c7721b..7a79922 100644 --- a/src/lib/ai/copywriter.ts +++ b/src/lib/ai/copywriter.ts @@ -23,8 +23,19 @@ export interface CopyGenerationInput { tone?: string; language?: string; count?: number; + // Pistas de resposta direta (low-ticket / tripwire). Quando presentes, a copy + // lidera pela oferta e facilidade de pagamento em vez de só curiosidade. + offer?: string; + price?: string; + payment_methods?: string; + guarantee?: string; + urgency?: string; + is_low_ticket?: boolean; } +// Nichos tratados como resposta direta de ticket baixo por padrão. +const DIRECT_RESPONSE_CATEGORIES = ['festa_infantil', 'ecommerce', 'infoproduct', 'delivery']; + export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopyVariation[]> { const openai = getAIClient(); @@ -34,6 +45,20 @@ export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopy const categoryContext = input.category ? `Nicho/Categoria do Produto: ${input.category}\n` : ''; const rulesContext = input.copy_rules ? `Regras do Nicho: ${input.copy_rules}\n` : ''; + const isLowTicket = + input.is_low_ticket ?? + (input.category ? DIRECT_RESPONSE_CATEGORIES.includes(input.category) : false); + + // Bloco de oferta: só entra se houver dados reais (preço/pagamento/garantia). + const offerLines = [ + input.offer ? `Oferta principal: ${input.offer}` : '', + input.price ? `Preço/Ticket: ${input.price}` : '', + input.payment_methods ? `Formas de pagamento: ${input.payment_methods}` : '', + input.guarantee ? `Garantia: ${input.guarantee}` : '', + input.urgency ? `Urgência/Bônus: ${input.urgency}` : '', + ].filter(Boolean); + const offerContext = offerLines.length ? `\nOFERTA (use de forma central na copy):\n${offerLines.join('\n')}\n` : ''; + let websiteContext = ''; if (input.link) { const scrapedText = await scrapeWebsiteContext(input.link); @@ -42,24 +67,37 @@ export async function generateAdCopy(input: CopyGenerationInput): Promise<AdCopy } } + const directResponseRules = isLowTicket + ? ` +ESTRATÉGIA DE RESPOSTA DIRETA (PRODUTO DE ENTRADA / TICKET BAIXO): +- Este é um produto de ENTRADA: o objetivo do anúncio é a COMPRA imediata, não cliques curiosos. +- Lidere pela oferta concreta e pelo preço-âncora quando houver (ex: "Tudo isso por apenas R$X"). +- Se houver Pix, destaque "Pix = acesso/entrega imediata"; se houver parcelamento no cartão, mencione. +- Use contraste de valor (o que a pessoa recebe vs. quanto custa) e prova de praticidade. +- Urgência honesta (oferta de lançamento, bônus por tempo limitado) — nunca falsa escassez. +- Gere variações DIVERSAS para teste A/B: pelo menos uma liderada pelo PREÇO/OFERTA, uma liderada pelo BENEFÍCIO/TRANSFORMAÇÃO e uma liderada por DOR/URGÊNCIA. +- Prefira CTA de compra: SHOP_NOW ou GET_OFFER.` + : ''; + const prompt = `Você é um copywriter especialista em Facebook Ads com anos de experiência em conversão. Você gera textos otimizados para alta performance em anúncios do Meta (Facebook/Instagram). REGRAS OBRIGATÓRIAS: - Headline: máximo 40 caracteres, impactante e direto -- Primary Text: máximo 125 caracteres, gere curiosidade e urgência +- Primary Text: idealmente forte nos primeiros 125 caracteres (parte visível antes do "ver mais"); pode ter até ~500 caracteres se a oferta pedir mais detalhes. Não encha linguiça. - Description: máximo 30 caracteres, complemento da headline - CTA: use um dos CTAs oficiais do Meta (LEARN_MORE, SHOP_NOW, SIGN_UP, SUBSCRIBE, CONTACT_US, GET_OFFER, BOOK_NOW) - Idioma: ${language} - Tom: ${tone} - Foque em benefícios, não features -- Use gatilhos mentais (escassez, prova social, autoridade) +- Use gatilhos mentais (escassez, prova social, autoridade) com honestidade - Evite clickbait ou promessas impossíveis +${directResponseRules} Gere ${count} variações de copy para um anúncio de Facebook Ads baseado no contexto abaixo: ${input.context} Objetivo da Campanha: ${input.objective} -${categoryContext}${rulesContext}${websiteContext} +${categoryContext}${rulesContext}${offerContext}${websiteContext} Retorne um JSON com a seguinte estrutura: { "variations": [ diff --git a/src/lib/ai/creative.ts b/src/lib/ai/creative.ts index de69175..89b9d0b 100644 --- a/src/lib/ai/creative.ts +++ b/src/lib/ai/creative.ts @@ -11,6 +11,9 @@ export interface ImageGenerationInput { style: string; dimensions: '1080x1080' | '1080x1920' | '1200x628'; mood?: string; + // Texto curto de oferta/preço para estampar no criativo (ex: "Só R$9,99 no Pix"). + // Para low-ticket de resposta direta, preço no criativo costuma elevar o CTR. + offer_text?: string; } export interface GeneratedImage { @@ -29,6 +32,10 @@ export async function generateAdImage(input: ImageGenerationInput): Promise<Gene const mood = input.mood || 'moderno, clean, profissional'; const size = sizeMap[input.dimensions] || '1024x1024'; + const offerInstruction = input.offer_text + ? `\nSELO DE OFERTA: inclua de forma elegante e legível um selo/badge com o texto "${input.offer_text}" (ex: balão ou faixa com o preço). Deve ser nítido, curto e em destaque, sem poluir o restante da imagem. Português correto, sem erros de ortografia.` + : `\n- Não inclua muito texto na imagem, deixe o foco no visual (a Meta prefere menos texto).`; + const prompt = `Crie um criativo de anúncio (Facebook/Instagram Ad) altamente persuasivo e visualmente impactante. O contexto do anúncio e as regras do nicho são os seguintes: --- @@ -39,8 +46,7 @@ Mood: ${mood} Diretrizes Adicionais: - A imagem deve ser otimizada para anúncios, sem poluição visual. -- Foco em conversão e chamar a atenção nos primeiros segundos. -- Não inclua muito texto na imagem, deixe o foco no visual (a Meta prefere menos texto). +- Foco em conversão e chamar a atenção nos primeiros segundos.${offerInstruction} - O visual deve conversar diretamente com o contexto especificado acima.`; const openai = getAIClient(); diff --git a/src/lib/ai/scraper.ts b/src/lib/ai/scraper.ts index 6d6ae1e..2d7659b 100644 --- a/src/lib/ai/scraper.ts +++ b/src/lib/ai/scraper.ts @@ -1,68 +1,224 @@ import * as cheerio from 'cheerio'; /** - * Faz o fetch de uma URL e extrai o contexto principal (título, meta description e parágrafos) - * para fornecer inteligência para a IA de Copywriting e Público-Alvo. + * Lê uma URL e extrai o contexto principal (título, descrição, oferta/preço e + * textos da página) para alimentar a IA de análise, copy e público-alvo. + * + * Muitas landing pages modernas (incl. landpage.festamagicaia.com.br) são SPAs + * renderizadas em JavaScript — um fetch + cheerio puro só enxerga o <title> e as + * meta tags. Para esses casos, além de extrair og/JSON-LD (que geralmente vêm + * no HTML mesmo em SPA), caímos para um leitor que executa o JS (r.jina.ai) + * quando o HTML cru retorna pouco texto. */ export async function scrapeWebsiteContext(url: string, maxLength: number = 2000): Promise<string> { if (!url) return ''; - // Validação básica de URL para garantir que tem http:// ou https:// let targetUrl = url; if (!targetUrl.startsWith('http')) { targetUrl = `https://${targetUrl}`; } + // 1) Tentativa direta: rápida e suficiente para sites SSR/estáticos. + const direct = await scrapeStaticHtml(targetUrl); + + // Heurística: se o corpo extraído for muito curto, provavelmente é uma SPA + // que não renderizou o conteúdo no HTML inicial. Aí vale o leitor com JS. + const bodyIsThin = direct.bodyTextLength < 300; + + let renderedText = ''; + if (bodyIsThin) { + renderedText = await scrapeRenderedText(targetUrl); + } + + // 2) Monta o contexto combinando metadados estruturados (sempre úteis) com o + // melhor texto disponível (renderizado se houver, senão o estático). + const sections: string[] = []; + + if (direct.title) sections.push(`Título do Site: ${direct.title}`); + if (direct.description) sections.push(`Descrição (Meta): ${direct.description}`); + if (direct.ogTitle && direct.ogTitle !== direct.title) sections.push(`OG Título: ${direct.ogTitle}`); + if (direct.ogDescription && direct.ogDescription !== direct.description) { + sections.push(`OG Descrição: ${direct.ogDescription}`); + } + if (direct.structured.length > 0) { + sections.push(`\nDados Estruturados (produto/oferta/preço):\n${direct.structured.join('\n')}`); + } + + const mainText = renderedText || direct.bodyText; + if (mainText) { + sections.push(`\nTextos Principais da Página:\n${mainText}`); + } + + let context = sections.join('\n').trim(); + + if (context.length > maxLength) { + context = context.substring(0, maxLength) + '...'; + } + + return context; +} + +interface StaticScrapeResult { + title: string; + description: string; + ogTitle: string; + ogDescription: string; + structured: string[]; + bodyText: string; + bodyTextLength: number; +} + +/** Fetch + cheerio: pega metadados (og/twitter), JSON-LD e o texto visível. */ +async function scrapeStaticHtml(targetUrl: string): Promise<StaticScrapeResult> { + const empty: StaticScrapeResult = { + title: '', + description: '', + ogTitle: '', + ogDescription: '', + structured: [], + bodyText: '', + bodyTextLength: 0, + }; + try { const response = await fetch(targetUrl, { headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', }, - // Timeout curto para evitar gargalo na geração se o site for muito lento - signal: AbortSignal.timeout(5000) + signal: AbortSignal.timeout(6000), }); if (!response.ok) { console.warn(`[Scraper] Falha ao ler ${targetUrl}: ${response.statusText}`); - return ''; + return empty; } const html = await response.text(); const $ = cheerio.load(html); - // Remover tags desnecessárias que poluem o texto - $('script, style, noscript, nav, footer, header, iframe, svg').remove(); - - const title = $('title').text().trim() || ''; + const title = $('title').first().text().trim(); const description = $('meta[name="description"]').attr('content')?.trim() || ''; + const ogTitle = $('meta[property="og:title"]').attr('content')?.trim() || ''; + const ogDescription = $('meta[property="og:description"]').attr('content')?.trim() || ''; - // Coletar textos focados no conteúdo (Headers e Parágrafos) + // JSON-LD: costuma trazer Product/Offer com price/priceCurrency mesmo em SPA. + const structured = extractStructuredOffers($); + + // Texto visível: remove ruído e captura headings + parágrafos + itens de lista + // e spans de destaque (preços, selos), que importam para low-ticket. + $('script, style, noscript, svg, iframe').remove(); + + const seen = new Set<string>(); const elementsText: string[] = []; - - $('h1, h2, h3, p').each((_, element) => { - const text = $(element).text().trim().replace(/\s+/g, ' '); - if (text && text.length > 10) { + $('h1, h2, h3, h4, p, li, [class*="price" i], [class*="preco" i]').each((_, el) => { + const text = $(el).text().trim().replace(/\s+/g, ' '); + if (text && text.length > 6 && text.length < 400 && !seen.has(text)) { + seen.add(text); elementsText.push(text); } }); - let context = ''; - if (title) context += `Título do Site: ${title}\n`; - if (description) context += `Descrição (Meta): ${description}\n`; - - if (elementsText.length > 0) { - context += `\nTextos Principais da Página:\n`; - context += elementsText.join('\n'); - } + const bodyText = elementsText.join('\n'); - // Limitar o contexto para economizar tokens e focar na primeira dobra/promessa principal - if (context.length > maxLength) { - context = context.substring(0, maxLength) + '...'; - } - - return context.trim(); + return { + title, + description, + ogTitle, + ogDescription, + structured, + bodyText, + bodyTextLength: bodyText.length, + }; } catch (error) { - console.warn(`[Scraper] Erro ao tentar processar a URL ${targetUrl}:`, error); - return ''; // Falha silenciosa, a IA usará apenas o link cru como contexto + console.warn(`[Scraper] Erro ao processar ${targetUrl}:`, error); + return empty; + } +} + +/** + * Fallback para SPAs: r.jina.ai renderiza a página (executa JS) e devolve o + * conteúdo já em markdown/texto limpo. Best-effort — se falhar, seguimos só com + * o que o HTML estático deu. + */ +async function scrapeRenderedText(targetUrl: string): Promise<string> { + try { + const readerUrl = `https://r.jina.ai/${targetUrl}`; + const response = await fetch(readerUrl, { + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; ADsPRO/1.0; +https://metaads.pro)', + // Pede só o conteúdo principal, sem links/imagens, pra economizar tokens. + 'X-Return-Format': 'text', + }, + signal: AbortSignal.timeout(12000), + }); + + if (!response.ok) { + console.warn(`[Scraper] Leitor JS falhou em ${targetUrl}: ${response.statusText}`); + return ''; + } + + const text = (await response.text()).trim(); + // Colapsa linhas em branco repetidas para reduzir tokens. + return text.replace(/\n{3,}/g, '\n\n'); + } catch (error) { + console.warn(`[Scraper] Erro no leitor JS para ${targetUrl}:`, error); + return ''; + } +} + +/** Varre todos os <script type="application/ld+json"> atrás de preço/oferta. */ +function extractStructuredOffers($: cheerio.CheerioAPI): string[] { + const lines: string[] = []; + + $('script[type="application/ld+json"]').each((_, el) => { + const raw = $(el).contents().text().trim(); + if (!raw) return; + try { + const data = JSON.parse(raw); + collectOffers(data, lines); + } catch { + // JSON-LD malformado — ignora. + } + }); + + return [...new Set(lines)]; +} + +function collectOffers(node: unknown, out: string[]): void { + if (!node) return; + if (Array.isArray(node)) { + node.forEach((n) => collectOffers(n, out)); + return; + } + if (typeof node !== 'object') return; + + const obj = node as Record<string, unknown>; + const type = String(obj['@type'] || '').toLowerCase(); + + if (type.includes('product') || obj.name || obj.offers) { + const name = typeof obj.name === 'string' ? obj.name : ''; + const desc = typeof obj.description === 'string' ? obj.description : ''; + if (name) out.push(`Produto: ${name}`); + if (desc) out.push(`Descrição do produto: ${desc.slice(0, 300)}`); + } + + const offers = obj.offers; + if (offers) { + const list = Array.isArray(offers) ? offers : [offers]; + for (const offer of list) { + if (offer && typeof offer === 'object') { + const o = offer as Record<string, unknown>; + const price = o.price ?? o.lowPrice ?? o.highPrice; + const currency = o.priceCurrency ?? ''; + if (price !== undefined) out.push(`Preço/Oferta: ${currency} ${price}`.trim()); + } + } + } + + // Desce recursivamente para grafos (@graph) e nós aninhados. + for (const value of Object.values(obj)) { + if (value && typeof value === 'object') collectOffers(value, out); } } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 2d5fd4d..cef2f9d 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -7,12 +7,16 @@ export interface AppSettings { metaAppId: string; metaAppSecret: string; openaiApiKey: string; + // Pixel/Dataset ID usado para otimizar campanhas por COMPRA (Conversions API). + // Para low-ticket, é o que permite o Meta entregar para quem realmente compra. + metaPixelId: string; } const defaultSettings: AppSettings = { metaAppId: '', metaAppSecret: '', openaiApiKey: '', + metaPixelId: '', }; export function getSettings(): AppSettings {