fix: resolver instagram_actor_id válido p/ Ads + modal largo com scroll
IG: o id que vem da Página (instagram_business_account) é recusado pela API de Ads (1815199) mesmo com a conta autorizada. Agora resolvemos o "ator" correto via /act_X/instagram_accounts, casando pelo @username, nos fluxos de criar campanha e adicionar anúncio. Modal: .modal-content ganha max-height 90vh + overflow para não quebrar a tela ao gerar copy/imagem, e tamanho 'lg' (680px) usado no Adicionar Anúncio, que estava estreito demais. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
aebf5bd919
commit
77f4dba1b2
8 changed files with 81 additions and 7 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { createMetaMCPClient } from '@/lib/mcp/client';
|
import { createMetaMCPClient } from '@/lib/mcp/client';
|
||||||
import { addAdToAdSet, getAdSetDefaultCreative } from '@/lib/mcp/tools';
|
import { addAdToAdSet, getAdSetDefaultCreative, resolveInstagramActorId } from '@/lib/mcp/tools';
|
||||||
import { getAccessToken } from '@/lib/auth/session';
|
import { getAccessToken } from '@/lib/auth/session';
|
||||||
|
|
||||||
function isWhatsappAd(body: Record<string, unknown>) {
|
function isWhatsappAd(body: Record<string, unknown>) {
|
||||||
|
|
@ -97,6 +97,14 @@ export async function POST(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve o instagram_actor_id válido para Ads a partir da conta de anúncios
|
||||||
|
// (o id da Página costuma ser recusado com 1815199). Casa pelo @username.
|
||||||
|
finalInstagramActorId = await resolveInstagramActorId(mcpClient, {
|
||||||
|
username: body.instagram_username,
|
||||||
|
fallbackId: finalInstagramActorId,
|
||||||
|
adAccountId: ad_account_id,
|
||||||
|
});
|
||||||
|
|
||||||
const finalIsWhatsapp = isWhatsappAd({ ...body, cta: finalCta });
|
const finalIsWhatsapp = isWhatsappAd({ ...body, cta: finalCta });
|
||||||
|
|
||||||
if (!finalLink && !whatsapp_link && !whatsapp_phone_number) {
|
if (!finalLink && !whatsapp_link && !whatsapp_phone_number) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { createMetaMCPClient } from '@/lib/mcp/client';
|
import { createMetaMCPClient } from '@/lib/mcp/client';
|
||||||
import { createFullCampaign, getPages } from '@/lib/mcp/tools';
|
import { createFullCampaign, getPages, resolveInstagramActorId } from '@/lib/mcp/tools';
|
||||||
|
|
||||||
function toNumberOrUndefined(value: unknown): number | undefined {
|
function toNumberOrUndefined(value: unknown): number | undefined {
|
||||||
if (value === undefined || value === null || value === '') return undefined;
|
if (value === undefined || value === null || value === '') return undefined;
|
||||||
|
|
@ -247,6 +247,14 @@ export async function POST(req: Request) {
|
||||||
// análise/entrega na Meta.
|
// análise/entrega na Meta.
|
||||||
const finalStatus = body.activate === true ? 'ACTIVE' : 'PAUSED';
|
const finalStatus = body.activate === true ? 'ACTIVE' : 'PAUSED';
|
||||||
|
|
||||||
|
// Resolve o instagram_actor_id correto para Ads (o id da Página costuma ser
|
||||||
|
// recusado com 1815199). Casa pelo @username vindo do formulário.
|
||||||
|
const resolvedInstagramActorId = await resolveInstagramActorId(mcpClient, {
|
||||||
|
username: body.instagram_username,
|
||||||
|
fallbackId: instagram_actor_id,
|
||||||
|
adAccountId: ad_account_id,
|
||||||
|
});
|
||||||
|
|
||||||
const baseTargeting = targeting || { geo_locations: { countries: ['BR'] } };
|
const baseTargeting = targeting || { geo_locations: { countries: ['BR'] } };
|
||||||
const finalTargeting: Record<string, unknown> = {
|
const finalTargeting: Record<string, unknown> = {
|
||||||
...baseTargeting,
|
...baseTargeting,
|
||||||
|
|
@ -256,7 +264,7 @@ export async function POST(req: Request) {
|
||||||
// Sem identidade de Instagram, o anúncio não pode rodar em posicionamentos
|
// Sem identidade de Instagram, o anúncio não pode rodar em posicionamentos
|
||||||
// do Instagram (a Meta recusa com subcode 1772103). Nesse caso restringimos
|
// do Instagram (a Meta recusa com subcode 1772103). Nesse caso restringimos
|
||||||
// aos posicionamentos que não exigem IG (Facebook/Audience Network/Messenger).
|
// aos posicionamentos que não exigem IG (Facebook/Audience Network/Messenger).
|
||||||
if (!instagram_actor_id && !(baseTargeting as any).publisher_platforms) {
|
if (!resolvedInstagramActorId && !(baseTargeting as any).publisher_platforms) {
|
||||||
finalTargeting.publisher_platforms = ['facebook', 'audience_network', 'messenger'];
|
finalTargeting.publisher_platforms = ['facebook', 'audience_network', 'messenger'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,7 +277,7 @@ export async function POST(req: Request) {
|
||||||
bid_amount: finalBidStrategy === 'LOWEST_COST_WITHOUT_CAP' ? undefined : finalBidAmount,
|
bid_amount: finalBidStrategy === 'LOWEST_COST_WITHOUT_CAP' ? undefined : finalBidAmount,
|
||||||
targeting: finalTargeting,
|
targeting: finalTargeting,
|
||||||
page_id: finalPageId,
|
page_id: finalPageId,
|
||||||
instagram_actor_id: instagram_actor_id || undefined,
|
instagram_actor_id: resolvedInstagramActorId || undefined,
|
||||||
image_urls,
|
image_urls,
|
||||||
image_bytes,
|
image_bytes,
|
||||||
link,
|
link,
|
||||||
|
|
|
||||||
|
|
@ -551,6 +551,9 @@ export default function CreateCampaignPage() {
|
||||||
|
|
||||||
instagram_actor_id: selectedInstagramAccount?.id || undefined,
|
instagram_actor_id: selectedInstagramAccount?.id || undefined,
|
||||||
|
|
||||||
|
// username pra resolver o "ator" de IG correto na conta de anúncios.
|
||||||
|
instagram_username: selectedInstagramAccount?.username || undefined,
|
||||||
|
|
||||||
pixel_id: form.pixel_id || undefined,
|
pixel_id: form.pixel_id || undefined,
|
||||||
|
|
||||||
conversion_event: form.conversion_event,
|
conversion_event: form.conversion_event,
|
||||||
|
|
|
||||||
|
|
@ -464,9 +464,18 @@ body {
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
|
/* Evita transbordar a tela quando o conteúdo cresce (gerar copy/imagem):
|
||||||
|
limita a altura e rola por dentro em vez de quebrar o layout. */
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
animation: fadeInUp 0.3s ease;
|
animation: fadeInUp 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal largo (formulários maiores, ex: adicionar anúncio). */
|
||||||
|
.modal-content.modal-lg {
|
||||||
|
max-width: 680px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Autofill Override ---- */
|
/* ---- Autofill Override ---- */
|
||||||
input:-webkit-autofill,
|
input:-webkit-autofill,
|
||||||
input:-webkit-autofill:hover,
|
input:-webkit-autofill:hover,
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,7 @@ export function AddAdModal({
|
||||||
campaign_name: campaignName,
|
campaign_name: campaignName,
|
||||||
page_id: effectivePageId || defaults?.page_id,
|
page_id: effectivePageId || defaults?.page_id,
|
||||||
instagram_actor_id: selectedInstagramAccount?.id || undefined,
|
instagram_actor_id: selectedInstagramAccount?.id || undefined,
|
||||||
|
instagram_username: selectedInstagramAccount?.username || undefined,
|
||||||
link: link.trim() || undefined,
|
link: link.trim() || undefined,
|
||||||
headline: selectedCopyData.headline,
|
headline: selectedCopyData.headline,
|
||||||
primary_text: selectedCopyData.primary_text,
|
primary_text: selectedCopyData.primary_text,
|
||||||
|
|
@ -283,7 +284,7 @@ export function AddAdModal({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title="Adicionar Anúncio">
|
<Modal isOpen={isOpen} onClose={onClose} title="Adicionar Anúncio" size="lg">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>
|
<p className="text-sm" style={{ color: 'var(--muted-fg)' }}>
|
||||||
Campanha: <span className="text-white font-medium">{campaignName}</span>
|
Campanha: <span className="text-white font-medium">{campaignName}</span>
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,19 @@ interface ModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/** 'lg' deixa o modal mais largo, para formulários maiores. */
|
||||||
|
size?: 'default' | 'lg';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
export function Modal({ isOpen, onClose, title, children, size = 'default' }: ModalProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className={`modal-content${size === 'lg' ? ' modal-lg' : ''}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,16 @@ export async function callMetaTool(
|
||||||
return { content: [{ type: 'text', text: JSON.stringify(allPages) }] };
|
return { content: [{ type: 'text', text: JSON.stringify(allPages) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contas de Instagram que a CONTA DE ANÚNCIOS pode usar como identidade
|
||||||
|
// (o "ator" pra anúncios). O id daqui é o que serve em instagram_actor_id,
|
||||||
|
// diferente do instagram_business_account.id que vem da Página.
|
||||||
|
case 'list_ig_accounts': {
|
||||||
|
const data = await fetchGraphGet(`/${accountId}/instagram_accounts`, token, {
|
||||||
|
fields: 'id,username',
|
||||||
|
});
|
||||||
|
return { content: [{ type: 'text', text: JSON.stringify(data.data || []) }] };
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicação nativa (mesmo "Duplicar" do Ads Manager) via endpoint /copies.
|
// Duplicação nativa (mesmo "Duplicar" do Ads Manager) via endpoint /copies.
|
||||||
// Sempre cria como PAUSED e com sufixo no nome pra identificar a cópia.
|
// Sempre cria como PAUSED e com sufixo no nome pra identificar a cópia.
|
||||||
case 'copy_campaign': {
|
case 'copy_campaign': {
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,36 @@ export async function getPages(client: Client) {
|
||||||
return extractContent(result);
|
return extractContent(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve o instagram_actor_id correto para anúncios. O id que vem da Página
|
||||||
|
* (instagram_business_account.id) costuma ser recusado pela API de Ads com erro
|
||||||
|
* 1815199. O id válido é o que a CONTA DE ANÚNCIOS expõe em /act_X/instagram_accounts.
|
||||||
|
* Casamos pelo @username; se não achar, devolve o fallback (id da página) para
|
||||||
|
* o pipeline seguir e, no pior caso, cair no fallback de Facebook.
|
||||||
|
*/
|
||||||
|
export async function resolveInstagramActorId(
|
||||||
|
client: Client,
|
||||||
|
opts: { username?: string; fallbackId?: string; adAccountId?: string }
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
try {
|
||||||
|
const result = await callMetaTool(client, 'list_ig_accounts', { ad_account_id: opts.adAccountId });
|
||||||
|
const list = extractContent(result) as Array<{ id: string; username?: string }> | null;
|
||||||
|
|
||||||
|
if (Array.isArray(list) && list.length > 0) {
|
||||||
|
if (opts.username) {
|
||||||
|
const wanted = opts.username.replace(/^@/, '').toLowerCase();
|
||||||
|
const match = list.find((a) => a.username?.toLowerCase() === wanted);
|
||||||
|
if (match?.id) return match.id;
|
||||||
|
}
|
||||||
|
// Conta com uma única identidade de IG disponível → usa ela.
|
||||||
|
if (list.length === 1 && list[0]?.id) return list[0].id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Sem acesso à listagem — segue com o id informado.
|
||||||
|
}
|
||||||
|
return opts.fallbackId;
|
||||||
|
}
|
||||||
|
|
||||||
// ==============================
|
// ==============================
|
||||||
// Ad Set Management
|
// Ad Set Management
|
||||||
// ==============================
|
// ==============================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue