feat: otimizar app para campanhas de conversão low-ticket
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 <title> - 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>
This commit is contained in:
parent
9bda154730
commit
a995424725
12 changed files with 497 additions and 84 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,17 +1149,31 @@ 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 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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs uppercase tracking-wider font-medium mb-2 text-blue-400">
|
||||
ID do Pixel (Recomendado para Vendas)
|
||||
Pixel / Dataset ID *
|
||||
</label>
|
||||
|
||||
<input
|
||||
className="input-field"
|
||||
value={form.pixel_id}
|
||||
|
|
@ -1133,6 +1185,34 @@ export default function CreateCampaignPage() {
|
|||
}
|
||||
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 */}
|
||||
|
|
|
|||
|
|
@ -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,7 +38,7 @@ 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;
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
const bodyText = elementsText.join('\n');
|
||||
|
||||
if (elementsText.length > 0) {
|
||||
context += `\nTextos Principais da Página:\n`;
|
||||
context += 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue