Compare commits
2 commits
b4ad2e0585
...
a88e96adf5
| Author | SHA1 | Date | |
|---|---|---|---|
| a88e96adf5 | |||
| dcaf7959ac |
3 changed files with 174 additions and 25 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Wand2, Upload, CheckCircle2 } from 'lucide-react';
|
import { Wand2, Upload, CheckCircle2 } from 'lucide-react';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Spinner } from '@/components/ui/Spinner';
|
import { Spinner } from '@/components/ui/Spinner';
|
||||||
|
|
@ -8,6 +8,12 @@ import { ErrorBlock } from '@/components/ui/ErrorBlock';
|
||||||
import { parseApiError, type FriendlyError } from '@/lib/errors';
|
import { parseApiError, type FriendlyError } from '@/lib/errors';
|
||||||
import type { AdCopyVariation } from '@/lib/ai/copywriter';
|
import type { AdCopyVariation } from '@/lib/ai/copywriter';
|
||||||
|
|
||||||
|
type MetaPage = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
instagram_account: { id: string; username: string; profile_picture_url?: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
interface AddAdModalProps {
|
interface AddAdModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -45,6 +51,23 @@ export function AddAdModal({
|
||||||
const [defaults, setDefaults] = useState<CreativeDefaults | null>(null);
|
const [defaults, setDefaults] = useState<CreativeDefaults | null>(null);
|
||||||
const [defaultsLoading, setDefaultsLoading] = useState(false);
|
const [defaultsLoading, setDefaultsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [pages, setPages] = useState<MetaPage[]>([]);
|
||||||
|
const [pagesLoading, setPagesLoading] = useState(false);
|
||||||
|
const [selectedPageId, setSelectedPageId] = useState('');
|
||||||
|
|
||||||
|
// Página detectada automaticamente a partir do anúncio existente no ad set
|
||||||
|
// (ou a primeira da lista, se não houver match) — usada como valor inicial
|
||||||
|
// do seletor até o usuário escolher outra explicitamente.
|
||||||
|
const autoSelectedPageId = useMemo(() => {
|
||||||
|
if (pages.length === 0) return '';
|
||||||
|
const preferred = defaults?.page_id ? pages.find((p) => p.id === defaults.page_id) : undefined;
|
||||||
|
return preferred?.id || pages[0].id;
|
||||||
|
}, [pages, defaults]);
|
||||||
|
|
||||||
|
const effectivePageId = selectedPageId || autoSelectedPageId;
|
||||||
|
const selectedPage = pages.find((p) => p.id === effectivePageId) || null;
|
||||||
|
const selectedInstagramAccount = selectedPage?.instagram_account || null;
|
||||||
|
|
||||||
const [copyLoading, setCopyLoading] = useState(false);
|
const [copyLoading, setCopyLoading] = useState(false);
|
||||||
const [copyVariations, setCopyVariations] = useState<AdCopyVariation[]>([]);
|
const [copyVariations, setCopyVariations] = useState<AdCopyVariation[]>([]);
|
||||||
const [selectedCopy, setSelectedCopy] = useState<number | null>(null);
|
const [selectedCopy, setSelectedCopy] = useState<number | null>(null);
|
||||||
|
|
@ -57,6 +80,7 @@ export function AddAdModal({
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [error, setError] = useState<FriendlyError | string | null>(null);
|
const [error, setError] = useState<FriendlyError | string | null>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
const [instagramDropped, setInstagramDropped] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
@ -90,6 +114,32 @@ export function AddAdModal({
|
||||||
};
|
};
|
||||||
}, [adsetId, campaignName, adsetName]);
|
}, [adsetId, campaignName, adsetName]);
|
||||||
|
|
||||||
|
// Carrega as páginas/Instagram conectados para o usuário poder escolher
|
||||||
|
// explicitamente — em vez de depender só da detecção automática, que pode
|
||||||
|
// trazer uma combinação não autorizada para esta conta de anúncios.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
setPagesLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/meta/pages');
|
||||||
|
const data = await res.json();
|
||||||
|
if (!cancelled && data.success) {
|
||||||
|
setPages(data.data || []);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Segue sem a lista — o usuário ainda pode publicar com os defaults detectados.
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setPagesLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const generateCopy = async () => {
|
const generateCopy = async () => {
|
||||||
if (!context.trim()) {
|
if (!context.trim()) {
|
||||||
setError('Descreva sobre o que é esse anúncio antes de gerar as copies.');
|
setError('Descreva sobre o que é esse anúncio antes de gerar as copies.');
|
||||||
|
|
@ -181,11 +231,12 @@ export function AddAdModal({
|
||||||
const canPublish =
|
const canPublish =
|
||||||
!!selectedCopyData &&
|
!!selectedCopyData &&
|
||||||
!!selectedImage &&
|
!!selectedImage &&
|
||||||
|
!!effectivePageId &&
|
||||||
(!!link.trim() || !!defaults?.whatsapp_link || !!defaults?.whatsapp_phone_number);
|
(!!link.trim() || !!defaults?.whatsapp_link || !!defaults?.whatsapp_phone_number);
|
||||||
|
|
||||||
const publish = async () => {
|
const publish = async () => {
|
||||||
if (!canPublish || !selectedCopyData || !selectedImage) {
|
if (!canPublish || !selectedCopyData || !selectedImage) {
|
||||||
setError('Selecione uma copy e uma imagem antes de publicar.');
|
setError('Selecione uma página, uma copy e uma imagem antes de publicar.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -200,8 +251,8 @@ export function AddAdModal({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
campaign_name: campaignName,
|
campaign_name: campaignName,
|
||||||
page_id: defaults?.page_id,
|
page_id: effectivePageId || defaults?.page_id,
|
||||||
instagram_actor_id: defaults?.instagram_actor_id,
|
instagram_actor_id: selectedInstagramAccount?.id || 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,
|
||||||
|
|
@ -221,6 +272,7 @@ export function AddAdModal({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setInstagramDropped(Boolean(data.data?.instagram_dropped));
|
||||||
setDone(true);
|
setDone(true);
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -251,10 +303,62 @@ export function AddAdModal({
|
||||||
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
|
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
|
||||||
Ele foi criado como Pausado — ative-o no Ads Manager (ou na tabela) quando estiver pronto.
|
Ele foi criado como Pausado — ative-o no Ads Manager (ou na tabela) quando estiver pronto.
|
||||||
</p>
|
</p>
|
||||||
|
{instagramDropped && (
|
||||||
|
<div className="text-[11px] mt-1 px-3 py-2 rounded-lg text-left" style={{ background: 'rgba(234,179,8,0.08)', border: '1px solid rgba(234,179,8,0.2)', color: '#fcd34d' }}>
|
||||||
|
⚠️ Publicamos <strong>só no Facebook</strong>: a conta de anúncios ainda não tem permissão pra usar
|
||||||
|
{selectedInstagramAccount ? <> a conta <strong>@{selectedInstagramAccount.username}</strong></> : ' a conta do Instagram'} desta página.
|
||||||
|
Para ativar o Instagram, vincule a conta no Business Manager (Configurações do Negócio → Contas do Instagram → Atribuir conta de anúncios) e republique.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button onClick={onClose} className="btn-primary mt-2">Fechar</button>
|
<button onClick={onClose} className="btn-primary mt-2">Fechar</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
|
||||||
|
Página do Facebook que vai promover *
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{pagesLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs py-2" style={{ color: 'var(--muted-fg)' }}>
|
||||||
|
<Spinner size="sm" /> Carregando páginas conectadas...
|
||||||
|
</div>
|
||||||
|
) : pages.length === 0 ? (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--muted-fg)' }}>
|
||||||
|
Nenhuma página encontrada na sua conta do Facebook. A Meta vai usar a página padrão deste ad set.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={effectivePageId}
|
||||||
|
onChange={(e) => setSelectedPageId(e.target.value)}
|
||||||
|
className="input-field appearance-none w-full cursor-pointer pr-10"
|
||||||
|
>
|
||||||
|
{pages.map((page) => (
|
||||||
|
<option key={page.id} value={page.id}>
|
||||||
|
{page.name}
|
||||||
|
{page.instagram_account ? ` · Instagram: @${page.instagram_account.username}` : ' · sem Instagram vinculado'}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center px-3" style={{ color: 'var(--muted-fg)' }}>
|
||||||
|
<svg className="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.293 12.95l.707.707L15.657 8l-1.414-1.414L10 10.828 5.757 6.586 4.343 8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[11px] mt-1.5" style={{ color: 'var(--muted-fg)' }}>
|
||||||
|
{selectedInstagramAccount ? (
|
||||||
|
<>O anúncio também vai rodar no Instagram conectado a essa página: <strong>@{selectedInstagramAccount.username}</strong>.</>
|
||||||
|
) : (
|
||||||
|
'Esta página não tem uma conta do Instagram vinculada — o anúncio roda só no Facebook.'
|
||||||
|
)}
|
||||||
|
{' '}Se a Meta recusar por falta de permissão entre a conta de anúncios e o Instagram, escolha outra página aqui ou autorize o vínculo no Business Manager.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
|
<label className="block text-xs uppercase tracking-wider font-medium mb-2" style={{ color: 'var(--muted-fg)' }}>
|
||||||
Sobre o que é esse anúncio?
|
Sobre o que é esse anúncio?
|
||||||
|
|
@ -262,10 +366,15 @@ export function AddAdModal({
|
||||||
<textarea
|
<textarea
|
||||||
value={context}
|
value={context}
|
||||||
onChange={(e) => setContext(e.target.value)}
|
onChange={(e) => setContext(e.target.value)}
|
||||||
placeholder="Ex: Promoção de fim de ano, novo recurso do produto, depoimento de cliente..."
|
placeholder="Ex: Topo de bolo personalizado para festa infantil, com balões e confete coloridos, convite digital de aniversário..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="input-field text-sm"
|
className="input-field text-sm"
|
||||||
/>
|
/>
|
||||||
|
<p className="text-[11px] mt-1.5" style={{ color: 'var(--muted-fg)' }}>
|
||||||
|
💡 Para gerar fotos relevantes, descreva o <strong>produto/cena</strong> que a imagem deve mostrar
|
||||||
|
(não só o texto de venda) — ex.: "topo de bolo com nome da criança, decoração de festa de aniversário".
|
||||||
|
Pré-preenchemos com a copy do anúncio atual; edite à vontade antes de gerar.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Copy */}
|
{/* Copy */}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,24 @@ async function fetchGraphGet(path: string, token: string, params: Record<string,
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper para requests GET que percorre todas as páginas (paging.next) —
|
||||||
|
// necessário pra endpoints como /me/accounts, que retornam só um lote por vez.
|
||||||
|
async function fetchGraphGetAll(path: string, token: string, params: Record<string, string | number> = {}) {
|
||||||
|
const first = await fetchGraphGet(path, token, { limit: 100, ...params });
|
||||||
|
const results: unknown[] = [...(first.data || [])];
|
||||||
|
|
||||||
|
let nextUrl: string | undefined = first.paging?.next;
|
||||||
|
while (nextUrl) {
|
||||||
|
const res = await fetch(nextUrl);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) throwMetaError(data.error);
|
||||||
|
results.push(...(data.data || []));
|
||||||
|
nextUrl = data.paging?.next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper para requests POST com JSON body
|
// Helper para requests POST com JSON body
|
||||||
async function fetchGraphPostJson(path: string, token: string, body: any = {}) {
|
async function fetchGraphPostJson(path: string, token: string, body: any = {}) {
|
||||||
const url = new URL(`https://graph.facebook.com/v20.0${path}`);
|
const url = new URL(`https://graph.facebook.com/v20.0${path}`);
|
||||||
|
|
@ -118,10 +136,10 @@ export async function callMetaTool(
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'list_pages': {
|
case 'list_pages': {
|
||||||
resultData = await fetchGraphGet('/me/accounts', token, {
|
const allPages = await fetchGraphGetAll('/me/accounts', token, {
|
||||||
fields: 'id,name,access_token,instagram_business_account{id,username,profile_picture_url}',
|
fields: 'id,name,access_token,instagram_business_account{id,username,profile_picture_url}',
|
||||||
});
|
});
|
||||||
return { content: [{ type: 'text', text: JSON.stringify(resultData.data || []) }] };
|
return { content: [{ type: 'text', text: JSON.stringify(allPages) }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'get_insights_for_account':
|
case 'get_insights_for_account':
|
||||||
|
|
|
||||||
|
|
@ -608,26 +608,30 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
||||||
// the most broadly compatible across placements.
|
// the most broadly compatible across placements.
|
||||||
const primaryImageHash = imageHashes[0];
|
const primaryImageHash = imageHashes[0];
|
||||||
|
|
||||||
let creative;
|
// Monta o criativo com (ou sem) o Instagram. Quando a conta de anúncios não
|
||||||
if (params.is_whatsapp) {
|
// tem permissão pra usar a conta de IG vinculada à página, a Meta recusa o
|
||||||
creative = await createWhatsappAdCreative(client, {
|
// criativo inteiro (erro 200 / subcode 1815199). Nesse caso refazemos o
|
||||||
|
// criativo só com o Facebook, em vez de barrar a publicação.
|
||||||
|
const buildCreative = (instagramActorId?: string) => {
|
||||||
|
if (params.is_whatsapp) {
|
||||||
|
return createWhatsappAdCreative(client, {
|
||||||
|
name: creativeName,
|
||||||
|
page_id: params.page_id,
|
||||||
|
instagram_actor_id: instagramActorId,
|
||||||
|
image_hash: primaryImageHash,
|
||||||
|
message: params.primary_text,
|
||||||
|
headline: params.headline,
|
||||||
|
description: params.description,
|
||||||
|
link: params.link,
|
||||||
|
whatsapp_link: params.whatsapp_link,
|
||||||
|
whatsapp_phone_number: params.whatsapp_phone_number,
|
||||||
|
ad_account_id: params.ad_account_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return createAdCreative(client, {
|
||||||
name: creativeName,
|
name: creativeName,
|
||||||
page_id: params.page_id,
|
page_id: params.page_id,
|
||||||
instagram_actor_id: params.instagram_actor_id,
|
instagram_actor_id: instagramActorId,
|
||||||
image_hash: primaryImageHash,
|
|
||||||
message: params.primary_text,
|
|
||||||
headline: params.headline,
|
|
||||||
description: params.description,
|
|
||||||
link: params.link,
|
|
||||||
whatsapp_link: params.whatsapp_link,
|
|
||||||
whatsapp_phone_number: params.whatsapp_phone_number,
|
|
||||||
ad_account_id: params.ad_account_id,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
creative = await createAdCreative(client, {
|
|
||||||
name: creativeName,
|
|
||||||
page_id: params.page_id,
|
|
||||||
instagram_actor_id: params.instagram_actor_id,
|
|
||||||
image_hash: primaryImageHash,
|
image_hash: primaryImageHash,
|
||||||
link: params.link,
|
link: params.link,
|
||||||
message: params.primary_text,
|
message: params.primary_text,
|
||||||
|
|
@ -636,6 +640,23 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
||||||
call_to_action_type: params.cta,
|
call_to_action_type: params.cta,
|
||||||
ad_account_id: params.ad_account_id,
|
ad_account_id: params.ad_account_id,
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1815199: "A conta de anúncios não tem acesso a esta conta do Instagram".
|
||||||
|
const isInstagramPermissionError = (err: unknown) =>
|
||||||
|
err instanceof Error && /1815199/.test(err.message);
|
||||||
|
|
||||||
|
let creative;
|
||||||
|
let instagramDropped = false;
|
||||||
|
try {
|
||||||
|
creative = await buildCreative(params.instagram_actor_id);
|
||||||
|
} catch (err) {
|
||||||
|
if (params.instagram_actor_id && isInstagramPermissionError(err)) {
|
||||||
|
creative = await buildCreative(undefined);
|
||||||
|
instagramDropped = true;
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const creativeId = (creative as any)?.id;
|
const creativeId = (creative as any)?.id;
|
||||||
|
|
@ -659,6 +680,7 @@ export async function addAdToAdSet(client: Client, params: AddAdToAdSetParams) {
|
||||||
adset_id: params.adset_id,
|
adset_id: params.adset_id,
|
||||||
creative_id: creativeId,
|
creative_id: creativeId,
|
||||||
ad_id: adId,
|
ad_id: adId,
|
||||||
|
instagram_dropped: instagramDropped,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue