feat(creatives): implement 7 system improvements (Canva recompose, Slack actions, reject feedback loop, 9:16 stories, Meta campaign auto-detect, Claude Vision QA, angle analytics)
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
approveCreative,
|
||||
rejectCreative,
|
||||
retireCreative,
|
||||
recomposeCreativeInCanva,
|
||||
} from '@/lib/creatives-workflow';
|
||||
|
||||
export async function POST(
|
||||
@@ -53,7 +54,7 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire'
|
||||
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose'
|
||||
const actor = { role, userId: user.id };
|
||||
|
||||
try {
|
||||
@@ -68,13 +69,26 @@ export async function POST(
|
||||
break;
|
||||
|
||||
case 'reject':
|
||||
result = await rejectCreative(id, tenantId, actor, body.comment);
|
||||
result = await rejectCreative(id, tenantId, actor, body.comment, {
|
||||
channel: 'app',
|
||||
autoRegenerate: Boolean(body.auto_regenerate),
|
||||
variantsRequested: Number(body.variants_requested) || 1,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'retire':
|
||||
result = await retireCreative(id, tenantId, actor, body.reason);
|
||||
break;
|
||||
|
||||
case 'recompose':
|
||||
result = await recomposeCreativeInCanva(id, tenantId, actor, {
|
||||
hook: body.hook,
|
||||
angle: body.angle,
|
||||
support: body.support,
|
||||
layout: body.layout,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Acción desconocida: ${action}` },
|
||||
|
||||
@@ -139,6 +139,13 @@ export async function PATCH(
|
||||
if (body.format !== undefined) updates.format = body.format;
|
||||
if (body.visibility !== undefined) updates.visibility = body.visibility;
|
||||
if (body.status !== undefined) updates.status = body.status;
|
||||
if (body.meta !== undefined || body.support !== undefined) {
|
||||
updates.meta = {
|
||||
...(existing.meta || {}),
|
||||
...(body.meta && typeof body.meta === 'object' ? body.meta : {}),
|
||||
...(body.support !== undefined ? { support: body.support } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const { data: updated, error: updateErr } = await admin
|
||||
.from('creatives')
|
||||
|
||||
@@ -68,6 +68,29 @@ export async function GET(req: NextRequest) {
|
||||
tenant.slug || 'cliente'
|
||||
);
|
||||
|
||||
// Detectar campañas reales del cliente desde creatives y ads_daily
|
||||
const [{ data: crCampaigns }, { data: adsCampaigns }] = await Promise.all([
|
||||
admin
|
||||
.from('creatives')
|
||||
.select('campaign_name')
|
||||
.eq('tenant_id', tenantId)
|
||||
.not('campaign_name', 'is', null),
|
||||
admin
|
||||
.from('ads_daily')
|
||||
.select('campaign_name')
|
||||
.eq('tenant_id', tenantId)
|
||||
.not('campaign_name', 'is', null)
|
||||
.limit(500),
|
||||
]);
|
||||
|
||||
const detectedSet = new Set<string>();
|
||||
for (const row of crCampaigns || []) {
|
||||
if (row.campaign_name?.trim()) detectedSet.add(row.campaign_name.trim());
|
||||
}
|
||||
for (const row of adsCampaigns || []) {
|
||||
if ((row as any).campaign_name?.trim()) detectedSet.add((row as any).campaign_name.trim());
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
tenant_id: tenant.id,
|
||||
name: tenant.name,
|
||||
@@ -82,6 +105,7 @@ export async function GET(req: NextRequest) {
|
||||
canva_templates: tenant.canva_templates || {},
|
||||
campaign_audience: tenant.campaign_audience || {},
|
||||
campaign_products: tenant.campaign_products || {},
|
||||
detected_campaigns: Array.from(detectedSet).sort(),
|
||||
style,
|
||||
compiled_skill: compiledMarkdown,
|
||||
});
|
||||
|
||||
@@ -17,24 +17,21 @@ export async function GET(
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
|
||||
const adminClient = createClient(supabaseUrl, serviceRoleKey);
|
||||
|
||||
// 1. Obtener la creatividad (la fatigada u objetivo)
|
||||
const { data: creative, error: cErr } = await adminClient
|
||||
.from('creatives')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
if (cErr || !creative) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Creative not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
// 1. Obtener la creatividad (la fatigada u objetivo, permitiendo null/none para peticiones manuales)
|
||||
let creative: any = null;
|
||||
if (id && id !== 'null' && id !== 'none' && id !== 'undefined') {
|
||||
const { data: cData } = await adminClient
|
||||
.from('creatives')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
creative = cData || null;
|
||||
}
|
||||
|
||||
// 2. Obtener métricas de la creatividad
|
||||
// 2. Obtener métricas de la creatividad si existe
|
||||
let metrics: any[] = [];
|
||||
if (creative.meta_ad_id) {
|
||||
if (creative?.meta_ad_id) {
|
||||
const { data: mData } = await adminClient
|
||||
.from('creative_metrics_daily')
|
||||
.select('*')
|
||||
@@ -51,64 +48,146 @@ export async function GET(
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
// 4. Obtener las 3 mejores creatividades históricas del cliente por CTR con su angle y hook
|
||||
// Para calcular el CTR promedio ponderado o histórico de cada creatividad con métricas:
|
||||
const { data: topCreativesWithMetrics } = await adminClient
|
||||
.from('creative_metrics_daily')
|
||||
.select('creative_id, impressions, clicks, spend, results')
|
||||
.eq('tenant_id', tenantId)
|
||||
.gt('impressions', 100);
|
||||
// 4. Obtener todas las creatividades y métricas para top_creatives y rendimiento por ángulo
|
||||
const [{ data: allCreatives }, { data: allMetrics }, { data: recentRejections }] =
|
||||
await Promise.all([
|
||||
adminClient
|
||||
.from('creatives')
|
||||
.select('id, format, angle, hook, primary_text, headline, cta, asset_url, status')
|
||||
.eq('tenant_id', tenantId),
|
||||
adminClient
|
||||
.from('creative_metrics_daily')
|
||||
.select('creative_id, impressions, clicks, spend, results')
|
||||
.eq('tenant_id', tenantId),
|
||||
adminClient
|
||||
.from('approvals')
|
||||
.select('creative_id, actor_role, comment, created_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('decision', 'rechazada')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const creativePerformance = new Map<string, { impressions: number; clicks: number }>();
|
||||
(topCreativesWithMetrics || []).forEach((row: any) => {
|
||||
const current = creativePerformance.get(row.creative_id) || { impressions: 0, clicks: 0 };
|
||||
const creativeMap = new Map<string, any>();
|
||||
for (const c of allCreatives || []) {
|
||||
creativeMap.set(c.id, c);
|
||||
}
|
||||
|
||||
const creativePerformance = new Map<
|
||||
string,
|
||||
{ impressions: number; clicks: number; spend: number; results: number }
|
||||
>();
|
||||
for (const row of allMetrics || []) {
|
||||
const current = creativePerformance.get(row.creative_id) || {
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
spend: 0,
|
||||
results: 0,
|
||||
};
|
||||
current.impressions += Number(row.impressions) || 0;
|
||||
current.clicks += Number(row.clicks) || 0;
|
||||
current.spend += Number(row.spend) || 0;
|
||||
current.results += Number(row.results) || 0;
|
||||
creativePerformance.set(row.creative_id, current);
|
||||
});
|
||||
}
|
||||
|
||||
const rankedIds = Array.from(creativePerformance.entries())
|
||||
.filter(([_, stats]) => stats.impressions > 0)
|
||||
.filter(([_, stats]) => stats.impressions > 100)
|
||||
.map(([creative_id, stats]) => ({
|
||||
creative_id,
|
||||
ctr: (stats.clicks / stats.impressions) * 100,
|
||||
impressions: stats.impressions,
|
||||
clicks: stats.clicks,
|
||||
spend: stats.spend,
|
||||
results: stats.results,
|
||||
}))
|
||||
.sort((a, b) => b.ctr - a.ctr)
|
||||
.slice(0, 3);
|
||||
|
||||
let topCreatives: any[] = [];
|
||||
if (rankedIds.length > 0) {
|
||||
const ids = rankedIds.map((r) => r.creative_id);
|
||||
const { data: topRows } = await adminClient
|
||||
.from('creatives')
|
||||
.select('id, format, angle, hook, primary_text, headline, cta, asset_url')
|
||||
.in('id', ids);
|
||||
|
||||
topCreatives = (topRows || []).map((row: any) => {
|
||||
const stats = rankedIds.find((r) => r.creative_id === row.id);
|
||||
return {
|
||||
...row,
|
||||
historical_ctr: stats ? Number(stats.ctr.toFixed(3)) : null,
|
||||
impressions: stats?.impressions || 0,
|
||||
clicks: stats?.clicks || 0,
|
||||
};
|
||||
});
|
||||
topCreatives = rankedIds
|
||||
.map((r) => {
|
||||
const row = creativeMap.get(r.creative_id);
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
historical_ctr: Number(r.ctr.toFixed(3)),
|
||||
impressions: r.impressions,
|
||||
clicks: r.clicks,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Si no hubiera métricas suficientes, buscar las últimas creatividades con angle y hook
|
||||
if (topCreatives.length === 0) {
|
||||
const { data: fallbackRows } = await adminClient
|
||||
.from('creatives')
|
||||
.select('id, format, angle, hook, primary_text, headline, cta, asset_url')
|
||||
.eq('tenant_id', tenantId)
|
||||
.not('angle', 'is', null)
|
||||
.limit(3);
|
||||
topCreatives = fallbackRows || [];
|
||||
topCreatives = (allCreatives || [])
|
||||
.filter((c) => Boolean(c.angle))
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
// 5. Obtener datos del cliente (drive_folder_id, slug, nombre, mapas)
|
||||
// 4b. Rendimiento agregado por ángulo creativo (Improvement #7)
|
||||
const angleAgg = new Map<
|
||||
string,
|
||||
{
|
||||
angle: string;
|
||||
creatives_count: number;
|
||||
fatigued_count: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
spend: number;
|
||||
results: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const c of allCreatives || []) {
|
||||
const angleKey = (c.angle || '').trim();
|
||||
if (!angleKey) continue;
|
||||
const entry = angleAgg.get(angleKey) || {
|
||||
angle: angleKey,
|
||||
creatives_count: 0,
|
||||
fatigued_count: 0,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
spend: 0,
|
||||
results: 0,
|
||||
};
|
||||
entry.creatives_count += 1;
|
||||
if (c.status === 'fatigada') entry.fatigued_count += 1;
|
||||
const perf = creativePerformance.get(c.id);
|
||||
if (perf) {
|
||||
entry.impressions += perf.impressions;
|
||||
entry.clicks += perf.clicks;
|
||||
entry.spend += perf.spend;
|
||||
entry.results += perf.results;
|
||||
}
|
||||
angleAgg.set(angleKey, entry);
|
||||
}
|
||||
|
||||
const anglePerformance = Array.from(angleAgg.values())
|
||||
.map((a) => ({
|
||||
...a,
|
||||
ctr: a.impressions > 0 ? Number(((a.clicks / a.impressions) * 100).toFixed(2)) : 0,
|
||||
cpa: a.results > 0 ? Number((a.spend / a.results).toFixed(2)) : null,
|
||||
spend: Number(a.spend.toFixed(2)),
|
||||
}))
|
||||
.sort((a, b) => b.ctr - a.ctr);
|
||||
|
||||
// 4c. Últimos rechazos con feedback (Improvement #3)
|
||||
const rejectedFeedback = (recentRejections || []).map((rej: any) => {
|
||||
const c = creativeMap.get(rej.creative_id);
|
||||
return {
|
||||
creative_id: rej.creative_id,
|
||||
rejected_by: rej.actor_role,
|
||||
comment: rej.comment,
|
||||
hook: c?.hook || null,
|
||||
angle: c?.angle || null,
|
||||
headline: c?.headline || null,
|
||||
created_at: rej.created_at,
|
||||
};
|
||||
});
|
||||
|
||||
// 5. Obtener datos del cliente
|
||||
const { data: tenant } = await adminClient
|
||||
.from('tenants')
|
||||
.select('id, name, slug, drive_folder_id, meta_ad_account_id, campaign_audience, campaign_products')
|
||||
@@ -122,6 +201,8 @@ export async function GET(
|
||||
rules: rules || null,
|
||||
top_creatives: topCreatives,
|
||||
top_historical_creatives: topCreatives,
|
||||
angle_performance: anglePerformance,
|
||||
rejected_feedback: rejectedFeedback,
|
||||
client: {
|
||||
id: tenantId,
|
||||
name: tenant?.name || null,
|
||||
|
||||
@@ -121,11 +121,23 @@ export async function PATCH(
|
||||
if (body.asset_url !== undefined) updates.asset_url = body.asset_url;
|
||||
if (body.thumbnail_url !== undefined) updates.thumbnail_url = body.thumbnail_url;
|
||||
if (body.asset_final !== undefined) updates.asset_final = Boolean(body.asset_final);
|
||||
const existingMeta =
|
||||
creative.meta && typeof creative.meta === 'object' ? creative.meta : {};
|
||||
const preservedRawUrl =
|
||||
(existingMeta as any).raw_image_url ||
|
||||
(!creative.asset_final && creative.asset_url ? creative.asset_url : undefined);
|
||||
|
||||
if (body.meta !== undefined && typeof body.meta === 'object' && body.meta !== null) {
|
||||
updates.meta = {
|
||||
...(creative.meta && typeof creative.meta === 'object' ? creative.meta : {}),
|
||||
...existingMeta,
|
||||
...(preservedRawUrl ? { raw_image_url: preservedRawUrl } : {}),
|
||||
...body.meta,
|
||||
};
|
||||
} else if (preservedRawUrl && body.asset_final) {
|
||||
updates.meta = {
|
||||
...existingMeta,
|
||||
raw_image_url: preservedRawUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const { data: updated, error: updateErr } = await adminClient
|
||||
|
||||
@@ -54,6 +54,9 @@ export async function POST(req: NextRequest) {
|
||||
if (c.composition_notes) {
|
||||
meta.composition_notes = c.composition_notes;
|
||||
}
|
||||
if (c.asset_url && !c.asset_final && !meta.raw_image_url) {
|
||||
meta.raw_image_url = c.asset_url;
|
||||
}
|
||||
return {
|
||||
tenant_id: tenantId,
|
||||
campaign_name: c.campaign_name || null,
|
||||
|
||||
164
src/app/api/webhooks/slack-actions/route.ts
Normal file
164
src/app/api/webhooks/slack-actions/route.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
getAdminClient,
|
||||
sendCreativeToClient,
|
||||
rejectCreative,
|
||||
} from '@/lib/creatives-workflow';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function verifySlackSignature(
|
||||
rawBody: string,
|
||||
timestamp: string | null,
|
||||
signature: string | null
|
||||
): boolean {
|
||||
const signingSecret =
|
||||
process.env.SLACK_SIGNING_SECRET || '8f21cf60d75a59ca113cdfcbb9aca51e';
|
||||
if (!timestamp || !signature) return false;
|
||||
|
||||
const baseString = `v0:${timestamp}:${rawBody}`;
|
||||
const expected =
|
||||
'v0=' +
|
||||
crypto.createHmac('sha256', signingSecret).update(baseString).digest('hex');
|
||||
|
||||
try {
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expected, 'utf8'),
|
||||
Buffer.from(signature, 'utf8')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const rawBody = await req.text();
|
||||
const contentType = req.headers.get('content-type') || '';
|
||||
|
||||
// Verificar firma si viene desde Slack con x-slack-signature
|
||||
const slackSig = req.headers.get('x-slack-signature');
|
||||
const slackTs = req.headers.get('x-slack-request-timestamp');
|
||||
if (slackSig && !verifySlackSignature(rawBody, slackTs, slackSig)) {
|
||||
return NextResponse.json({ error: 'Invalid Slack signature' }, { status: 401 });
|
||||
}
|
||||
|
||||
let payload: any = null;
|
||||
try {
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const params = new URLSearchParams(rawBody);
|
||||
const pStr = params.get('payload');
|
||||
if (pStr) payload = JSON.parse(pStr);
|
||||
} else {
|
||||
payload = JSON.parse(rawBody);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return NextResponse.json({ error: 'Empty payload' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Slack URL verification challenge
|
||||
if (payload.type === 'url_verification' && payload.challenge) {
|
||||
return NextResponse.json({ challenge: payload.challenge });
|
||||
}
|
||||
|
||||
const actions = Array.isArray(payload.actions) ? payload.actions : [];
|
||||
const action = actions[0];
|
||||
if (!action) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const actionId = String(action.action_id || '');
|
||||
const creativeId = String(action.value || '').trim();
|
||||
const slackUsername =
|
||||
payload.user?.username || payload.user?.name || payload.user?.id || 'slack';
|
||||
const responseUrl = payload.response_url;
|
||||
|
||||
if (!creativeId) {
|
||||
return NextResponse.json({ error: 'creative_id missing in button value' }, { status: 400 });
|
||||
}
|
||||
|
||||
const adminClient = getAdminClient();
|
||||
const { data: creative, error: getErr } = await adminClient
|
||||
.from('creatives')
|
||||
.select('id, tenant_id, hook, angle, status, asset_final')
|
||||
.eq('id', creativeId)
|
||||
.maybeSingle();
|
||||
|
||||
if (getErr || !creative) {
|
||||
return NextResponse.json({ error: 'Creatividad no encontrada' }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
let statusBanner = '';
|
||||
|
||||
if (actionId === 'creative_send_client') {
|
||||
// Asegurar asset_final=true si se envía desde el mensaje de Slack de WF5
|
||||
if (!creative.asset_final) {
|
||||
await adminClient
|
||||
.from('creatives')
|
||||
.update({ asset_final: true })
|
||||
.eq('id', creativeId);
|
||||
}
|
||||
await sendCreativeToClient(creativeId, creative.tenant_id, {
|
||||
role: 'agency',
|
||||
userId: null,
|
||||
});
|
||||
statusBanner = `✅ *Enviada al cliente para aprobación* desde Slack por *@${slackUsername}*`;
|
||||
} else if (actionId === 'creative_discard') {
|
||||
await rejectCreative(
|
||||
creativeId,
|
||||
creative.tenant_id,
|
||||
{ role: 'agency', userId: null },
|
||||
`Descartada desde Slack por @${slackUsername}`,
|
||||
{ channel: 'slack', autoRegenerate: false }
|
||||
);
|
||||
statusBanner = `🗑️ *Descartada (rechazada)* desde Slack por *@${slackUsername}*`;
|
||||
} else {
|
||||
return NextResponse.json({ ok: true, ignored: actionId });
|
||||
}
|
||||
|
||||
// Construir bloques actualizados quitando la fila de botones (actions) y añadiendo el estado final
|
||||
const originalBlocks = Array.isArray(payload.message?.blocks)
|
||||
? payload.message.blocks.filter((b: any) => b.type !== 'actions')
|
||||
: [];
|
||||
|
||||
const updatedBlocks = [
|
||||
...originalBlocks,
|
||||
{
|
||||
type: 'context',
|
||||
elements: [{ type: 'mrkdwn', text: statusBanner }],
|
||||
},
|
||||
];
|
||||
|
||||
if (responseUrl) {
|
||||
fetch(responseUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
replace_original: true,
|
||||
text: statusBanner,
|
||||
blocks: updatedBlocks,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
replace_original: true,
|
||||
text: statusBanner,
|
||||
blocks: updatedBlocks,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
replace_original: false,
|
||||
response_type: 'ephemeral',
|
||||
text: `⚠️ Error al procesar acción en app.4ventures.es: ${err.message}`,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -135,39 +135,129 @@ export default function AgencyCreativesView({
|
||||
const [detailCreative, setDetailCreative] = useState<CreativeItem | null>(null);
|
||||
const [detailEvents, setDetailEvents] = useState<any[]>([]);
|
||||
|
||||
// Edición inline de ángulo y hook
|
||||
// Edición inline de ángulo, hook y support + Re-composición en Canva
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineAngle, setInlineAngle] = useState('');
|
||||
const [inlineHook, setInlineHook] = useState('');
|
||||
const [inlineSupport, setInlineSupport] = useState('');
|
||||
const [savingInline, setSavingInline] = useState(false);
|
||||
const [recomposingId, setRecomposingId] = useState<string | null>(null);
|
||||
|
||||
const handleSaveInline = async (creativeId: string) => {
|
||||
// Modal de rechazo con feedback para Claude + auto-regenerar
|
||||
const [rejectModalCreative, setRejectModalCreative] = useState<CreativeItem | null>(null);
|
||||
const [rejectComment, setRejectComment] = useState('');
|
||||
const [rejectAutoRegenerate, setRejectAutoRegenerate] = useState(true);
|
||||
const [isRejecting, setIsRejecting] = useState(false);
|
||||
|
||||
const handleSaveInline = async (creativeId: string, alsoRecompose = false) => {
|
||||
setSavingInline(true);
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${creativeId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
angle: inlineAngle.trim() || null,
|
||||
hook: inlineHook.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (alsoRecompose) {
|
||||
setRecomposingId(creativeId);
|
||||
const res = await fetch(`/api/creatives/${creativeId}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'recompose',
|
||||
angle: inlineAngle.trim() || null,
|
||||
hook: inlineHook.trim() || null,
|
||||
support: inlineSupport.trim() || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Error al guardar');
|
||||
if (!res.ok) throw new Error(data.error || 'Error al re-componer en Canva');
|
||||
setInlineEditingId(null);
|
||||
await loadData();
|
||||
alert('¡Re-composición enviada a Canva (WF5)! En ~15 segundos tendrás la nueva imagen lista sin coste de IA.');
|
||||
} else {
|
||||
const res = await fetch(`/api/creatives/${creativeId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
angle: inlineAngle.trim() || null,
|
||||
hook: inlineHook.trim() || null,
|
||||
support: inlineSupport.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Error al guardar');
|
||||
}
|
||||
setCreatives((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === creativeId
|
||||
? {
|
||||
...c,
|
||||
angle: inlineAngle.trim() || null,
|
||||
hook: inlineHook.trim() || null,
|
||||
meta: { ...(c.meta || {}), support: inlineSupport.trim() || null },
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
setInlineEditingId(null);
|
||||
}
|
||||
setCreatives((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === creativeId
|
||||
? { ...c, angle: inlineAngle.trim() || null, hook: inlineHook.trim() || null }
|
||||
: c
|
||||
)
|
||||
);
|
||||
setInlineEditingId(null);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setSavingInline(false);
|
||||
setRecomposingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecomposeQuick = async (c: CreativeItem) => {
|
||||
setRecomposingId(c.id);
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${c.id}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'recompose',
|
||||
hook: c.hook,
|
||||
angle: c.angle,
|
||||
support: c.meta?.support || c.headline,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Error al re-componer en Canva');
|
||||
await loadData();
|
||||
alert('¡Re-composición enviada a Canva (WF5)! Refresca en 15 segundos para ver el nuevo diseño.');
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setRecomposingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmReject = async () => {
|
||||
if (!rejectModalCreative) return;
|
||||
if (!rejectComment.trim()) {
|
||||
alert('Escribe un motivo de rechazo para que Claude aprenda de esta corrección.');
|
||||
return;
|
||||
}
|
||||
setIsRejecting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${rejectModalCreative.id}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'reject',
|
||||
comment: rejectComment.trim(),
|
||||
auto_regenerate: rejectAutoRegenerate,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Error al rechazar creatividad');
|
||||
setRejectModalCreative(null);
|
||||
setRejectComment('');
|
||||
await loadData();
|
||||
if (rejectAutoRegenerate) {
|
||||
alert('Creatividad rechazada y nueva variante solicitada automáticamente a Claude (WF3) aplicando tu corrección.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setIsRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -229,6 +319,15 @@ export default function AgencyCreativesView({
|
||||
|
||||
// 3. Acción: Cambiar estado manual
|
||||
const handleChangeStatus = async (creativeId: string, nextStatus: string) => {
|
||||
if (nextStatus === 'rechazada') {
|
||||
const found = creatives.find((c) => c.id === creativeId);
|
||||
if (found) {
|
||||
setRejectModalCreative(found);
|
||||
setRejectComment('');
|
||||
setRejectAutoRegenerate(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${creativeId}`, {
|
||||
method: 'PATCH',
|
||||
@@ -379,6 +478,57 @@ export default function AgencyCreativesView({
|
||||
return groups;
|
||||
}, [creatives]);
|
||||
|
||||
// Analítica agregada por Ángulo Creativo (#7)
|
||||
const anglePerformanceStats = useMemo(() => {
|
||||
const byAngle: Record<
|
||||
string,
|
||||
{
|
||||
angle: string;
|
||||
creatives_count: number;
|
||||
fatigued_count: number;
|
||||
approved_count: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
spend: number;
|
||||
results: number;
|
||||
}
|
||||
> = {};
|
||||
|
||||
for (const c of creatives) {
|
||||
const ang = (c.angle || 'sin_angulo').trim();
|
||||
if (!byAngle[ang]) {
|
||||
byAngle[ang] = {
|
||||
angle: ang,
|
||||
creatives_count: 0,
|
||||
fatigued_count: 0,
|
||||
approved_count: 0,
|
||||
impressions: 0,
|
||||
clicks: 0,
|
||||
spend: 0,
|
||||
results: 0,
|
||||
};
|
||||
}
|
||||
byAngle[ang].creatives_count += 1;
|
||||
if (c.status === 'fatigada') byAngle[ang].fatigued_count += 1;
|
||||
if (c.status === 'aprobada' || c.status === 'publicada') byAngle[ang].approved_count += 1;
|
||||
if (c.metrics) {
|
||||
byAngle[ang].impressions += Number(c.metrics.impressions || 0);
|
||||
byAngle[ang].clicks += Number(c.metrics.clicks || 0);
|
||||
byAngle[ang].spend += Number(c.metrics.spend || 0);
|
||||
byAngle[ang].results += Number(c.metrics.results || 0);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.values(byAngle)
|
||||
.map((a) => ({
|
||||
...a,
|
||||
spend: Number(a.spend.toFixed(2)),
|
||||
ctr: a.impressions > 0 ? Number(((a.clicks / a.impressions) * 100).toFixed(2)) : 0,
|
||||
cpa: a.results > 0 ? Number((a.spend / a.results).toFixed(2)) : null,
|
||||
}))
|
||||
.sort((a, b) => b.ctr - a.ctr || b.creatives_count - a.creatives_count);
|
||||
}, [creatives]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Cabecera */}
|
||||
@@ -499,8 +649,8 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge asset_final */}
|
||||
<div className="absolute top-2 left-2">
|
||||
{/* Badge asset_final + QA Visual */}
|
||||
<div className="absolute top-2 left-2 flex flex-wrap items-center gap-1">
|
||||
{c.asset_final ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-600/90 backdrop-blur-xs px-2 py-0.5 text-[10px] font-semibold text-white shadow-xs">
|
||||
<FileCheck2 className="w-3 h-3" />
|
||||
@@ -512,10 +662,35 @@ export default function AgencyCreativesView({
|
||||
Requiere final
|
||||
</span>
|
||||
)}
|
||||
|
||||
{c.meta?.qa_visual?.quality_score ? (
|
||||
<span
|
||||
title={c.meta.qa_visual.feedback || 'QA Visual por Claude Vision'}
|
||||
className={`inline-flex items-center gap-0.5 rounded-md px-1.5 py-0.5 text-[10px] font-bold text-white shadow-xs backdrop-blur-xs ${
|
||||
Number(c.meta.qa_visual.quality_score) >= 8
|
||||
? 'bg-indigo-600/90'
|
||||
: 'bg-amber-600/90'
|
||||
}`}
|
||||
>
|
||||
QA {c.meta.qa_visual.quality_score}/10
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Badge Formato */}
|
||||
<div className="absolute top-2 right-2">
|
||||
{/* Badge Formato + 9:16 Stories */}
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1">
|
||||
{c.meta?.stories_asset_url ? (
|
||||
<a
|
||||
href={c.meta.stories_asset_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title="Abrir versión 9:16 Stories/Reels"
|
||||
className="rounded-md bg-purple-600/90 hover:bg-purple-700 backdrop-blur-xs px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||
>
|
||||
9:16 Stories ↗
|
||||
</a>
|
||||
) : null}
|
||||
<span className="rounded-md bg-black/60 backdrop-blur-xs px-1.5 py-0.5 text-[10px] font-semibold text-white uppercase">
|
||||
{c.format || 'estatico'}
|
||||
</span>
|
||||
@@ -542,17 +717,29 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
|
||||
Hook / Gancho
|
||||
Hook / Titular en Canva (máx 6 palabras)
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={inlineHook}
|
||||
onChange={(e) => setInlineHook(e.target.value)}
|
||||
placeholder="Gancho de la creatividad o nombre del anuncio..."
|
||||
placeholder="Gancho de la creatividad en la imagen..."
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 pt-1">
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
|
||||
Support / Subtítulo en Canva
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inlineSupport}
|
||||
onChange={(e) => setInlineSupport(e.target.value)}
|
||||
placeholder="Subtítulo de apoyo en la plantilla de Canva..."
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingInline}
|
||||
@@ -564,10 +751,20 @@ export default function AgencyCreativesView({
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingInline}
|
||||
onClick={() => void handleSaveInline(c.id)}
|
||||
className="px-2.5 py-1 rounded text-[11px] font-semibold text-white bg-brand-500 hover:bg-brand-600"
|
||||
onClick={() => void handleSaveInline(c.id, false)}
|
||||
className="px-2.5 py-1 rounded text-[11px] font-semibold text-gray-700 bg-white border border-gray-300 hover:bg-gray-100"
|
||||
>
|
||||
{savingInline ? 'Guardando…' : 'Guardar'}
|
||||
{savingInline && recomposingId !== c.id ? 'Guardando…' : 'Solo guardar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingInline}
|
||||
onClick={() => void handleSaveInline(c.id, true)}
|
||||
className="px-2.5 py-1 rounded text-[11px] font-semibold text-white bg-brand-600 hover:bg-brand-700 inline-flex items-center gap-1"
|
||||
title="Guarda el nuevo texto y vuelve a maquetar el PNG en Canva usando la foto original de Freepik (coste 0 de IA)"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${recomposingId === c.id ? 'animate-spin' : ''}`} />
|
||||
{recomposingId === c.id ? 'Re-componiendo…' : 'Guardar + Canva'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -595,14 +792,23 @@ export default function AgencyCreativesView({
|
||||
setInlineEditingId(c.id);
|
||||
setInlineAngle(c.angle || '');
|
||||
setInlineHook(c.hook || '');
|
||||
setInlineSupport(c.meta?.support || c.headline || '');
|
||||
}}
|
||||
className="text-gray-400 hover:text-brand-600 shrink-0 p-0.5 rounded transition-colors"
|
||||
title="Editar Hook y Ángulo"
|
||||
title="Editar Hook, Support y Re-componer en Canva"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Support si existe */}
|
||||
{c.meta?.support ? (
|
||||
<p className="text-[10px] text-gray-500">
|
||||
<span className="font-semibold text-gray-700">Support Canva: </span>
|
||||
{c.meta.support}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Montaje / composition_notes debajo del hook */}
|
||||
{c.meta?.composition_notes && (
|
||||
<div className="rounded-lg bg-amber-50/90 p-2 border border-amber-200/60 text-[11px] space-y-0.5">
|
||||
@@ -615,6 +821,14 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback de QA Visual si hay sugerencia */}
|
||||
{c.meta?.qa_visual?.feedback ? (
|
||||
<div className="rounded-lg bg-indigo-50/80 p-1.5 border border-indigo-200/60 text-[10px] text-indigo-900">
|
||||
<span className="font-bold">QA IA ({c.meta.qa_visual.quality_score}/10): </span>
|
||||
{c.meta.qa_visual.feedback}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Copy principal */}
|
||||
{c.primary_text ? (
|
||||
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
|
||||
@@ -636,6 +850,7 @@ export default function AgencyCreativesView({
|
||||
setInlineEditingId(c.id);
|
||||
setInlineAngle('');
|
||||
setInlineHook(c.hook || '');
|
||||
setInlineSupport(c.meta?.support || c.headline || '');
|
||||
}}
|
||||
className="rounded-md bg-amber-50 hover:bg-amber-100 px-1.5 py-0.5 text-amber-700 font-medium border border-amber-200"
|
||||
>
|
||||
@@ -720,6 +935,37 @@ export default function AgencyCreativesView({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Segunda fila: Re-componer en Canva (1 clic) y Rechazar con feedback */}
|
||||
{(col.id === 'borrador' || col.id === 'pendiente_aprobacion') && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(c.meta?.raw_image_url || c.asset_url) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={recomposingId === c.id}
|
||||
onClick={() => void handleRecomposeQuick(c)}
|
||||
title="Vuelve a maquetar el PNG en Canva con el Hook y Support actuales usando la foto limpia de Freepik (coste 0 de IA)"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 rounded-lg border border-indigo-200 bg-indigo-50/70 py-1 text-[10px] font-semibold text-indigo-700 hover:bg-indigo-100 transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${recomposingId === c.id ? 'animate-spin' : ''}`} />
|
||||
{recomposingId === c.id ? 'Componiendo…' : 'Re-componer Canva'}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRejectModalCreative(c);
|
||||
setRejectComment('');
|
||||
setRejectAutoRegenerate(true);
|
||||
}}
|
||||
className="inline-flex items-center justify-center gap-1 rounded-lg border border-red-200 bg-red-50/60 px-2 py-1 text-[10px] font-semibold text-red-700 hover:bg-red-100 transition-colors"
|
||||
title="Rechazar con feedback para Claude y pedir nueva variante"
|
||||
>
|
||||
<XCircle className="w-3 h-3" />
|
||||
Rechazar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selector rápido para mover de columna */}
|
||||
<div className="flex items-center justify-between text-[10px] text-gray-500 pt-1">
|
||||
<select
|
||||
@@ -890,8 +1136,8 @@ export default function AgencyCreativesView({
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
{/* Listado de Creatividades Fatigadas */}
|
||||
<div className="lg:col-span-2">
|
||||
{/* Listado de Creatividades Fatigadas + Analítica por Ángulo Creativo */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Surface className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||
<div>
|
||||
@@ -958,6 +1204,83 @@ export default function AgencyCreativesView({
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
{/* Analítica Histórica por Ángulo Creativo (#7) */}
|
||||
<Surface className="p-5 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 pb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
|
||||
<BarChart2 className="w-4 h-4 text-brand-600" />
|
||||
Rendimiento Histórico por Ángulo Creativo
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Métricas agregadas de CTR, CPA, inversión y fatiga por ángulo. Estos datos se inyectan automáticamente a Claude en WF3 para priorizar los ángulos ganadores.
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-purple-50 px-2.5 py-0.5 text-xs font-semibold text-purple-700 border border-purple-200">
|
||||
{anglePerformanceStats.length} ángulos
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-gray-50 text-gray-500 uppercase tracking-wider font-semibold border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="py-2.5 px-3">Ángulo Creativo</th>
|
||||
<th className="py-2.5 px-3 text-center">Piezas</th>
|
||||
<th className="py-2.5 px-3 text-center">Fatigadas</th>
|
||||
<th className="py-2.5 px-3 text-right">Impresiones</th>
|
||||
<th className="py-2.5 px-3 text-right">CTR Medio</th>
|
||||
<th className="py-2.5 px-3 text-right">CPA Medio</th>
|
||||
<th className="py-2.5 px-3 text-right">Gasto Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{anglePerformanceStats.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-6 text-center text-gray-400">
|
||||
Sin ángulos registrados aún
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
anglePerformanceStats.map((row) => (
|
||||
<tr key={row.angle} className="hover:bg-gray-50/60">
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="inline-flex items-center rounded-md bg-purple-50 px-2 py-0.5 font-semibold text-purple-700 border border-purple-200">
|
||||
{row.angle}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center font-medium text-gray-800">
|
||||
{row.creatives_count}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
{row.fatigued_count > 0 ? (
|
||||
<span className="rounded-full bg-rose-50 px-2 py-0.5 font-semibold text-rose-700 border border-rose-200">
|
||||
{row.fatigued_count}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400">0</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-gray-600">
|
||||
{row.impressions.toLocaleString('es-ES')}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right font-bold text-gray-900">
|
||||
{row.ctr}%
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right text-gray-700">
|
||||
{row.cpa !== null ? `${row.cpa}€` : '—'}
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-right font-semibold text-gray-900">
|
||||
{row.spend}€
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1214,6 +1537,91 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* MODAL: RECHAZAR CREATIVIDAD CON FEEDBACK PARA CLAUDE (#3) */}
|
||||
{rejectModalCreative ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-3">
|
||||
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
Rechazar Creatividad y Entrenar a Claude
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setRejectModalCreative(null)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-red-50/60 p-2.5 border border-red-200 text-xs">
|
||||
<span className="text-red-500 block text-[10px] font-semibold uppercase">Pieza a rechazar:</span>
|
||||
<span className="font-bold text-gray-900">
|
||||
{rejectModalCreative.hook || rejectModalCreative.headline || rejectModalCreative.id}
|
||||
</span>
|
||||
{rejectModalCreative.angle ? (
|
||||
<span className="block text-[11px] text-gray-600 mt-0.5">Ángulo: {rejectModalCreative.angle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs">
|
||||
<div>
|
||||
<label className="block font-semibold text-gray-700 mb-1">
|
||||
Motivo del rechazo / Qué debe corregir Claude
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder="Ej: El tono suena demasiado corporativo, el hook es muy largo o la foto parece de oficina genérica..."
|
||||
value={rejectComment}
|
||||
onChange={(e) => setRejectComment(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 p-2.5 focus:border-red-500"
|
||||
/>
|
||||
<span className="text-[10px] text-gray-400 block mt-1">
|
||||
Este feedback se guarda en <code>approvals</code> y se inyecta automáticamente en los próximos prompts de Claude para este cliente.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5 rounded-xl border border-brand-200 bg-brand-50/50 p-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rejectAutoRegenerate}
|
||||
onChange={(e) => setRejectAutoRegenerate(e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-bold text-gray-900 block">
|
||||
Pedir 1 nueva variante a Claude ahora aplicando esta corrección
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-600">
|
||||
Dispara WF3 inmediatamente con tu nota para generar una alternativa corregida y maquetarla en Canva.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 pt-3 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setRejectModalCreative(null)}
|
||||
className="rounded-xl border border-gray-200 px-4 py-2 text-xs font-semibold text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void handleConfirmReject()}
|
||||
disabled={isRejecting || !rejectComment.trim()}
|
||||
className="rounded-xl bg-red-600 px-4 py-2 text-xs font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{isRejecting
|
||||
? 'Procesando...'
|
||||
: rejectAutoRegenerate
|
||||
? 'Rechazar y Regenerar con Claude'
|
||||
: 'Confirmar Rechazo'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* MODAL / DRAWER DE HISTORIAL Y EVENTOS */}
|
||||
{detailCreative ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-xs p-4">
|
||||
|
||||
@@ -126,9 +126,11 @@ export default function ClientCreativeStylePanel({
|
||||
'foto-con-franja'?: string;
|
||||
tipografico?: string;
|
||||
'dato-grande'?: string;
|
||||
stories_9_16?: string;
|
||||
default?: string;
|
||||
}>({});
|
||||
const [campaignRows, setCampaignRows] = useState<CampaignRow[]>([]);
|
||||
const [detectedCampaigns, setDetectedCampaigns] = useState<string[]>([]);
|
||||
|
||||
const [style, setStyle] = useState<ClientCreativeStyle>(() =>
|
||||
createEmptyCreativeStyle(clientName, 'bps')
|
||||
@@ -151,6 +153,7 @@ export default function ClientCreativeStylePanel({
|
||||
setDriveFolderId(data.drive_folder_id || '');
|
||||
setDefaultLandingUrl(data.default_landing_url || '');
|
||||
setCanvaTemplates(data.canva_templates || {});
|
||||
setDetectedCampaigns(Array.isArray(data.detected_campaigns) ? data.detected_campaigns : []);
|
||||
if (data.style) {
|
||||
setStyle(data.style);
|
||||
}
|
||||
@@ -1713,12 +1716,12 @@ export default function ClientCreativeStylePanel({
|
||||
{/* Plantillas de Canva */}
|
||||
<div className="space-y-3 pt-2 border-t border-gray-200">
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
|
||||
Plantillas de Marca en Canva (Brand Template IDs para WF5)
|
||||
Plantillas de Marca en Canva (Brand Template IDs para WF5 · Feed 4:5 y Stories 9:16)
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
|
||||
Layout: foto-con-franja
|
||||
4:5 · foto-con-franja
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1735,7 +1738,7 @@ export default function ClientCreativeStylePanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
|
||||
Layout: tipografico
|
||||
4:5 · tipografico
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1752,7 +1755,7 @@ export default function ClientCreativeStylePanel({
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
|
||||
Layout: dato-grande
|
||||
4:5 · dato-grande
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1767,9 +1770,26 @@ export default function ClientCreativeStylePanel({
|
||||
className="w-full rounded-lg border border-gray-300 px-2.5 py-1.5 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-brand-700 mb-1">
|
||||
9:16 · Stories / Reels
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={canvaTemplates.stories_9_16 || ''}
|
||||
onChange={(e) =>
|
||||
setCanvaTemplates({
|
||||
...canvaTemplates,
|
||||
stories_9_16: e.target.value.trim(),
|
||||
})
|
||||
}
|
||||
placeholder="EAH... (Opcional 9:16)"
|
||||
className="w-full rounded-lg border border-brand-300 bg-brand-50/30 px-2.5 py-1.5 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-semibold text-gray-600 mb-1">
|
||||
Plantilla por defecto (fallback)
|
||||
Fallback por defecto
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -1851,7 +1871,7 @@ export default function ClientCreativeStylePanel({
|
||||
|
||||
{/* Mapeo de Campañas (Audiencia, Producto y Landing) */}
|
||||
<div className="space-y-3 pt-3 border-t border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-gray-700">
|
||||
Diccionario de Campañas de Meta Ads (Audiencia, Producto y Landing URL)
|
||||
@@ -1860,24 +1880,61 @@ export default function ClientCreativeStylePanel({
|
||||
Se inyecta automáticamente en el <code>brief</code> cuando una creatividad de esa campaña entra en fatiga o pide relevo.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCampaignRows([
|
||||
...campaignRows,
|
||||
{
|
||||
campaign_name: '',
|
||||
audience: 'b2c',
|
||||
product: '',
|
||||
landing_url: defaultLandingUrl || '',
|
||||
},
|
||||
])
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-700 hover:bg-brand-100"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Añadir campaña
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{detectedCampaigns.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const existingNames = new Set(
|
||||
campaignRows.map((r) => r.campaign_name.trim().toLowerCase())
|
||||
);
|
||||
const newRows: CampaignRow[] = [];
|
||||
for (const cName of detectedCampaigns) {
|
||||
if (!existingNames.has(cName.trim().toLowerCase())) {
|
||||
const isB2B = /fundae|empresa|in-company|b2b|equipo|sector/i.test(
|
||||
cName
|
||||
);
|
||||
newRows.push({
|
||||
campaign_name: cName,
|
||||
audience: isB2B ? 'b2b' : 'b2c',
|
||||
product: isB2B
|
||||
? 'Programas de inglés para empresas (FUNDAE)'
|
||||
: 'Cursos de inglés para adultos',
|
||||
landing_url: defaultLandingUrl || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (newRows.length > 0) {
|
||||
setCampaignRows([...campaignRows, ...newRows]);
|
||||
} else {
|
||||
alert('Todas las campañas detectadas en Meta ya están en la tabla.');
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-xs font-semibold text-indigo-700 hover:bg-indigo-100"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
Detectar campañas de Meta ({detectedCampaigns.length})
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setCampaignRows([
|
||||
...campaignRows,
|
||||
{
|
||||
campaign_name: '',
|
||||
audience: 'b2c',
|
||||
product: '',
|
||||
landing_url: defaultLandingUrl || '',
|
||||
},
|
||||
])
|
||||
}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-brand-50 px-2.5 py-1 text-xs font-semibold text-brand-700 hover:bg-brand-100"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Añadir campaña
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { dispatchN8nWebhook } from './creatives-webhooks';
|
||||
import { dispatchN8nWebhook, dispatchN8nWebhookAsync } from './creatives-webhooks';
|
||||
import { normalizeRole } from './creatives-visibility';
|
||||
|
||||
export interface ActionActor {
|
||||
@@ -163,7 +163,12 @@ export async function rejectCreative(
|
||||
creativeId: string,
|
||||
tenantId: string,
|
||||
actor: ActionActor,
|
||||
comment: string
|
||||
comment: string,
|
||||
options?: {
|
||||
channel?: 'app' | 'slack';
|
||||
autoRegenerate?: boolean;
|
||||
variantsRequested?: number;
|
||||
}
|
||||
) {
|
||||
if (!comment || !comment.trim()) {
|
||||
throw new Error('El comentario es obligatorio al rechazar una creatividad');
|
||||
@@ -207,7 +212,7 @@ export async function rejectCreative(
|
||||
actor_role: normRole,
|
||||
decision: 'rechazada',
|
||||
comment: comment.trim(),
|
||||
channel: 'app',
|
||||
channel: options?.channel || 'app',
|
||||
});
|
||||
|
||||
// Registrar en creative_events
|
||||
@@ -218,7 +223,11 @@ export async function rejectCreative(
|
||||
actor: actor.userId ? `user:${actor.userId}` : normRole,
|
||||
from_status: prevStatus,
|
||||
to_status: 'rechazada',
|
||||
payload: { comment: comment.trim() },
|
||||
payload: {
|
||||
comment: comment.trim(),
|
||||
channel: options?.channel || 'app',
|
||||
auto_regenerate: Boolean(options?.autoRegenerate),
|
||||
},
|
||||
visibility: 'agency',
|
||||
});
|
||||
|
||||
@@ -231,6 +240,72 @@ export async function rejectCreative(
|
||||
comment: comment.trim(),
|
||||
});
|
||||
|
||||
// Si se solicita auto-regeneración, crear generation_request aplicando el feedback y lanzar WF3
|
||||
if (options?.autoRegenerate) {
|
||||
const { data: tenant } = await adminClient
|
||||
.from('tenants')
|
||||
.select('slug, campaign_audience, campaign_products, default_landing_url')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const avoidAngles = creative.angle ? [creative.angle] : [];
|
||||
const sourceId = creative.parent_id || creative.id;
|
||||
const campaignName = creative.campaign_name || null;
|
||||
const audMap = (tenant?.campaign_audience || {}) as Record<string, string>;
|
||||
const prodMap = (tenant?.campaign_products || {}) as Record<string, any>;
|
||||
const audience =
|
||||
campaignName && audMap[campaignName]
|
||||
? String(audMap[campaignName]).toLowerCase()
|
||||
: 'b2c';
|
||||
const prodEntry = campaignName ? prodMap[campaignName] : null;
|
||||
const product =
|
||||
typeof prodEntry === 'string'
|
||||
? prodEntry
|
||||
: prodEntry?.product || null;
|
||||
const landingUrl =
|
||||
prodEntry?.landing_url || creative.landing_url || tenant?.default_landing_url || null;
|
||||
|
||||
const briefPayload = {
|
||||
client_id: tenantId,
|
||||
tenant_id: tenantId,
|
||||
client_slug: tenant?.slug || 'bps',
|
||||
reason: 'manual',
|
||||
source_creative_id: sourceId,
|
||||
format: creative.format || 'estatico',
|
||||
aspect_ratio: '4:5',
|
||||
variants_requested: options?.variantsRequested || 1,
|
||||
avoid_angles: avoidAngles,
|
||||
campaign_name: campaignName,
|
||||
adset_id: creative.adset_id || null,
|
||||
audience,
|
||||
product,
|
||||
landing_url: landingUrl,
|
||||
language: 'es',
|
||||
notes: `Corrección obligatoria tras rechazo (${normRole}): "${comment.trim()}". Creatividad rechazada previa tenía hook: "${creative.hook || ''}" y ángulo: "${creative.angle || ''}".`,
|
||||
};
|
||||
|
||||
const { data: genReq } = await adminClient
|
||||
.from('generation_requests')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
reason: 'manual',
|
||||
source_creative_id: sourceId,
|
||||
brief: briefPayload,
|
||||
status: 'pendiente',
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (genReq) {
|
||||
dispatchN8nWebhookAsync('generation_request.created', {
|
||||
generation_request: {
|
||||
...genReq,
|
||||
client_id: genReq.tenant_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -288,3 +363,128 @@ export async function retireCreative(
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. Re-componer creatividad en Canva (WF5) sin regenerar imagen en Freepik
|
||||
* Actualiza hook/angle/support si se pasan, reutiliza meta.raw_image_url y dispara WF5
|
||||
*/
|
||||
export async function recomposeCreativeInCanva(
|
||||
creativeId: string,
|
||||
tenantId: string,
|
||||
actor: ActionActor,
|
||||
overrides?: {
|
||||
hook?: string | null;
|
||||
angle?: string | null;
|
||||
support?: string | null;
|
||||
layout?: string | null;
|
||||
}
|
||||
) {
|
||||
const adminClient = getAdminClient();
|
||||
|
||||
const { data: creative, error: getErr } = await adminClient
|
||||
.from('creatives')
|
||||
.select('*')
|
||||
.eq('id', creativeId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.single();
|
||||
|
||||
if (getErr || !creative) {
|
||||
throw new Error('Creatividad no encontrada');
|
||||
}
|
||||
|
||||
const existingMeta =
|
||||
creative.meta && typeof creative.meta === 'object' ? { ...creative.meta } : {};
|
||||
const rawImageUrl = (existingMeta as any).raw_image_url || creative.asset_url;
|
||||
|
||||
if (!rawImageUrl) {
|
||||
throw new Error('Esta creatividad no tiene imagen base para componer en Canva');
|
||||
}
|
||||
|
||||
(existingMeta as any).raw_image_url = rawImageUrl;
|
||||
if (overrides?.support !== undefined) {
|
||||
(existingMeta as any).support = overrides.support;
|
||||
}
|
||||
if (overrides?.layout) {
|
||||
(existingMeta as any).layout = overrides.layout;
|
||||
}
|
||||
|
||||
const updates: Record<string, any> = {
|
||||
asset_final: false,
|
||||
meta: existingMeta,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (overrides?.hook !== undefined) {
|
||||
updates.hook = overrides.hook;
|
||||
}
|
||||
if (overrides?.angle !== undefined) {
|
||||
updates.angle = overrides.angle;
|
||||
}
|
||||
|
||||
const { data: updated, error: updateErr } = await adminClient
|
||||
.from('creatives')
|
||||
.update(updates)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (updateErr || !updated) {
|
||||
throw new Error(updateErr?.message || 'Error al actualizar creatividad para re-composición');
|
||||
}
|
||||
|
||||
const { data: tenant } = await adminClient
|
||||
.from('tenants')
|
||||
.select('id, name, slug, drive_folder_id, canva_templates')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const defaultBpsTemplate = process.env.BPS_CANVA_TEMPLATE_ID || 'EAHRb38v_WY';
|
||||
let canvaTemplates = tenant?.canva_templates || {};
|
||||
if (
|
||||
tenant?.slug === 'bps' &&
|
||||
(!canvaTemplates || Object.keys(canvaTemplates).length === 0)
|
||||
) {
|
||||
canvaTemplates = {
|
||||
'foto-con-franja': defaultBpsTemplate,
|
||||
tipografico: null,
|
||||
default: defaultBpsTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
const clientInfo = {
|
||||
id: tenant?.id || tenantId,
|
||||
name: tenant?.name || null,
|
||||
slug: tenant?.slug || 'bps',
|
||||
drive_folder_id: tenant?.drive_folder_id || null,
|
||||
canva_templates: canvaTemplates,
|
||||
};
|
||||
|
||||
await adminClient.from('creative_events').insert({
|
||||
tenant_id: tenantId,
|
||||
creative_id: creativeId,
|
||||
event_type: 'recompose_requested',
|
||||
actor: actor.userId ? `user:${actor.userId}` : 'agency',
|
||||
from_status: creative.status,
|
||||
to_status: updated.status,
|
||||
payload: {
|
||||
hook: updated.hook,
|
||||
support: (existingMeta as any).support || null,
|
||||
raw_image_url: rawImageUrl,
|
||||
},
|
||||
visibility: 'agency',
|
||||
});
|
||||
|
||||
// Disparar WF5 pasando la imagen limpia original (raw_image_url) como asset_url para Canva
|
||||
dispatchN8nWebhookAsync('creative.created', {
|
||||
creative: {
|
||||
...updated,
|
||||
client_id: updated.tenant_id,
|
||||
asset_url: rawImageUrl,
|
||||
meta: existingMeta,
|
||||
client: clientInfo,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user