Unify Ads/RRSS approval flow and add personalized Meta creative regen.
Agency validates first then client; Meta copy is 5+5+1 with on-image vs feed layers, emojis on feed, and partial v2 by image/hook/support/copy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -208,7 +208,7 @@ export async function POST(req: NextRequest) {
|
||||
publish_status: 'published',
|
||||
publish_error: null,
|
||||
published_at: new Date().toISOString(),
|
||||
status: 'publicado',
|
||||
status: 'publicada',
|
||||
meta_ig_media_id: result.igMediaId || null,
|
||||
meta_fb_post_id: result.fbPostId || null,
|
||||
caption: item.caption || null,
|
||||
@@ -240,9 +240,14 @@ export async function POST(req: NextRequest) {
|
||||
publish_error: null,
|
||||
scheduled_at: new Date().toISOString(),
|
||||
status:
|
||||
item.status === 'aprobado' || item.status === 'publicado'
|
||||
? item.status
|
||||
: 'aprobado',
|
||||
item.status === 'aprobada' ||
|
||||
item.status === 'publicada' ||
|
||||
item.status === 'aprobado' ||
|
||||
item.status === 'publicado'
|
||||
? item.status === 'publicado' || item.status === 'publicada'
|
||||
? 'publicada'
|
||||
: 'aprobada'
|
||||
: 'aprobada',
|
||||
});
|
||||
|
||||
// Publish IG then FB, persisting each id immediately (avoids IG duplicates on retry).
|
||||
@@ -307,7 +312,7 @@ export async function POST(req: NextRequest) {
|
||||
publish_status: 'published',
|
||||
publish_error: null,
|
||||
published_at: new Date().toISOString(),
|
||||
status: 'publicado',
|
||||
status: 'publicada',
|
||||
meta_ig_media_id: igMediaId,
|
||||
meta_fb_post_id: fbPostId,
|
||||
notes,
|
||||
|
||||
@@ -22,30 +22,21 @@ import {
|
||||
} from '@/lib/calendar-documents';
|
||||
import { processDuePipelinePublishes } from '@/lib/pipeline-publish-worker';
|
||||
import { sanitizePublishCaption } from '@/lib/publish-caption';
|
||||
import {
|
||||
CLIENT_ALLOWED_TRANSITIONS,
|
||||
CLIENT_VISIBLE_STATUSES,
|
||||
canSendToClient,
|
||||
normalizeApprovalStatus,
|
||||
parseSharedStatusWrite,
|
||||
type SharedApprovalStatus,
|
||||
} from '@/lib/approval-status';
|
||||
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||
|
||||
const SELECT =
|
||||
'id,external_ref,title,format,month,status,cover_path,cover_mime,slide_paths,caption,notes,approved_at,created_at,updated_at,scheduled_at,channels,publish_status,publish_attempts,published_at,meta_ig_media_id,meta_fb_post_id,publish_error,calendar_piece_id';
|
||||
|
||||
const FORMATS = new Set(['post', 'story', 'reel', 'carrusel']);
|
||||
|
||||
const ALL_STATUS = [
|
||||
'propuesta',
|
||||
'en_diseno',
|
||||
'revision_interna',
|
||||
'aprobado',
|
||||
'rechazado',
|
||||
'publicado',
|
||||
] as const;
|
||||
type Status = (typeof ALL_STATUS)[number];
|
||||
const STATUS_SET = new Set<string>(ALL_STATUS);
|
||||
|
||||
// El cliente solo puede mover una pieza que esté en revisión interna,
|
||||
// y únicamente hacia aprobar o rechazar. El resto de transiciones (incluida
|
||||
// "publicado") son cosa del equipo de la agencia.
|
||||
const CLIENT_ALLOWED_TRANSITIONS: Record<string, Status[]> = {
|
||||
revision_interna: ['aprobado', 'rechazado'],
|
||||
};
|
||||
|
||||
function str(v: unknown, max = 2000): string | null {
|
||||
if (typeof v !== 'string') return null;
|
||||
const t = v.trim();
|
||||
@@ -96,7 +87,10 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: await r.text() });
|
||||
}
|
||||
return NextResponse.json({
|
||||
items: redactScheduleForClient(await r.json(), isAgency),
|
||||
items: redactScheduleForClient(
|
||||
filterPipelineForRole(await r.json(), isAgency),
|
||||
isAgency
|
||||
),
|
||||
isAgency,
|
||||
});
|
||||
}
|
||||
@@ -115,11 +109,33 @@ export async function GET(req: NextRequest) {
|
||||
return NextResponse.json({ items: [], isAgency, warning: error.message });
|
||||
}
|
||||
return NextResponse.json({
|
||||
items: redactScheduleForClient(data ?? [], isAgency),
|
||||
items: redactScheduleForClient(
|
||||
filterPipelineForRole(data ?? [], isAgency),
|
||||
isAgency
|
||||
),
|
||||
isAgency,
|
||||
});
|
||||
}
|
||||
|
||||
function filterPipelineForRole<T extends { status?: string }>(
|
||||
rows: T[],
|
||||
isAgency: boolean
|
||||
): T[] {
|
||||
if (isAgency) {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
status: normalizeApprovalStatus(row.status),
|
||||
}));
|
||||
}
|
||||
const visible = new Set(CLIENT_VISIBLE_STATUSES);
|
||||
return rows
|
||||
.map((row) => ({
|
||||
...row,
|
||||
status: normalizeApprovalStatus(row.status),
|
||||
}))
|
||||
.filter((row) => visible.has(row.status as (typeof CLIENT_VISIBLE_STATUSES)[number]));
|
||||
}
|
||||
|
||||
function redactScheduleForClient<T extends Record<string, unknown>>(
|
||||
rows: T[],
|
||||
isAgency: boolean
|
||||
@@ -234,7 +250,7 @@ export async function POST(req: NextRequest) {
|
||||
month: month || null,
|
||||
caption: caption || null,
|
||||
notes: notes || null,
|
||||
status: 'propuesta',
|
||||
status: 'borrador',
|
||||
created_by: user.id,
|
||||
calendar_piece_id: resolvedCalendarId || null,
|
||||
};
|
||||
@@ -332,33 +348,77 @@ export async function PATCH(req: NextRequest) {
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (typeof body.status === 'string' && STATUS_SET.has(body.status)) {
|
||||
const next = body.status as Status;
|
||||
const currentNorm = normalizeApprovalStatus(current);
|
||||
const parsedNext =
|
||||
'status' in body ? parseSharedStatusWrite(body.status) : null;
|
||||
|
||||
if (parsedNext) {
|
||||
const next: SharedApprovalStatus = parsedNext;
|
||||
if (agency) {
|
||||
clean.status = next;
|
||||
} else {
|
||||
const allowed = CLIENT_ALLOWED_TRANSITIONS[current] || [];
|
||||
const allowed = CLIENT_ALLOWED_TRANSITIONS[currentNorm] || [];
|
||||
if (!allowed.includes(next)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Como cliente solo puedes aprobar o rechazar una pieza en revisión interna` },
|
||||
{
|
||||
error:
|
||||
'Como cliente solo puedes aprobar o rechazar una pieza pendiente de tu validación',
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
clean.status = next;
|
||||
}
|
||||
if (next === 'aprobado') {
|
||||
if (next === 'aprobada') {
|
||||
clean.approved_by = user.id;
|
||||
clean.approved_at = new Date().toISOString();
|
||||
// If already scheduled by agency, queue for the worker.
|
||||
if (agency && curRows?.[0]?.scheduled_at) {
|
||||
clean.publish_status = 'queued';
|
||||
}
|
||||
if (!agency) {
|
||||
dispatchN8nWebhookAsync('pipeline.approved', {
|
||||
client_id: ctx.tenantId,
|
||||
tenant_id: ctx.tenantId,
|
||||
pipeline_id: id,
|
||||
channel: 'rrss',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (next === 'rechazado' || next === 'propuesta' || next === 'en_diseno') {
|
||||
if (next === 'rechazada') {
|
||||
if (agency) {
|
||||
clean.publish_status = 'idle';
|
||||
} else {
|
||||
dispatchN8nWebhookAsync('pipeline.rejected', {
|
||||
client_id: ctx.tenantId,
|
||||
tenant_id: ctx.tenantId,
|
||||
pipeline_id: id,
|
||||
channel: 'rrss',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (next === 'borrador' || next === 'revision_agencia') {
|
||||
if (agency) {
|
||||
clean.publish_status = 'idle';
|
||||
}
|
||||
}
|
||||
// Agencia envía al cliente → aviso
|
||||
if (
|
||||
agency &&
|
||||
next === 'pendiente_aprobacion' &&
|
||||
canSendToClient(currentNorm)
|
||||
) {
|
||||
dispatchN8nWebhookAsync('pipeline.sent_to_client', {
|
||||
client_id: ctx.tenantId,
|
||||
tenant_id: ctx.tenantId,
|
||||
pipeline_id: id,
|
||||
channel: 'rrss',
|
||||
});
|
||||
}
|
||||
} else if ('status' in body && body.status != null) {
|
||||
return NextResponse.json(
|
||||
{ error: 'status inválido' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resto de campos: solo agencia los edita.
|
||||
@@ -394,10 +454,13 @@ export async function PATCH(req: NextRequest) {
|
||||
}
|
||||
clean.scheduled_at = parsed;
|
||||
if (parsed) {
|
||||
// Always arm the worker + ensure editorial status is aprobado.
|
||||
// (Cron only publishes queued/aprobado due items.)
|
||||
if (current !== 'publicado' && clean.status !== 'publicado') {
|
||||
clean.status = 'aprobado';
|
||||
// Cron publica queued + status aprobada. Programar implica ya validada.
|
||||
const nextStatus =
|
||||
typeof clean.status === 'string'
|
||||
? clean.status
|
||||
: currentNorm;
|
||||
if (nextStatus !== 'publicada') {
|
||||
clean.status = 'aprobada';
|
||||
clean.approved_by = user.id;
|
||||
clean.approved_at = new Date().toISOString();
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { resolveActiveTenant } from '@/lib/tenant-context';
|
||||
import { userRole } from '@/lib/agency';
|
||||
import {
|
||||
sendCreativeToClient,
|
||||
markCreativeAgencyReview,
|
||||
approveCreative,
|
||||
rejectCreative,
|
||||
retireCreative,
|
||||
recomposeCreativeInCanva,
|
||||
requestFeedCopyV2,
|
||||
requestCreativePartialV2,
|
||||
} from '@/lib/creatives-workflow';
|
||||
|
||||
export async function POST(
|
||||
@@ -54,12 +57,16 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
const action = body.action; // 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose'
|
||||
const action = body.action; // 'agency_review' | 'send_to_client' | 'approve' | 'reject' | 'retire' | 'recompose' | 'copy_v2'
|
||||
const actor = { role, userId: user.id };
|
||||
|
||||
try {
|
||||
let result: any;
|
||||
switch (action) {
|
||||
case 'agency_review':
|
||||
result = await markCreativeAgencyReview(id, tenantId, actor);
|
||||
break;
|
||||
|
||||
case 'send_to_client':
|
||||
result = await sendCreativeToClient(id, tenantId, actor);
|
||||
break;
|
||||
@@ -89,6 +96,48 @@ export async function POST(
|
||||
});
|
||||
break;
|
||||
|
||||
case 'copy_v2': {
|
||||
const keep = body.keep || {};
|
||||
result = await requestFeedCopyV2(
|
||||
id,
|
||||
tenantId,
|
||||
actor,
|
||||
{
|
||||
primary_indices: keep.primary_indices,
|
||||
headline_indices: keep.headline_indices,
|
||||
description: keep.description,
|
||||
},
|
||||
body.notes || body.comment || null
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'partial_v2': {
|
||||
const layers = body.layers || {};
|
||||
const keep = body.keep || body.feed_keep || {};
|
||||
result = await requestCreativePartialV2(
|
||||
id,
|
||||
tenantId,
|
||||
actor,
|
||||
{
|
||||
layers: {
|
||||
image: layers.image !== false,
|
||||
hook: layers.hook !== false,
|
||||
support: layers.support !== false,
|
||||
},
|
||||
hook_override: body.hook_override ?? body.hook ?? null,
|
||||
support_override: body.support_override ?? body.support ?? null,
|
||||
feed_keep: {
|
||||
primary_indices: keep.primary_indices,
|
||||
headline_indices: keep.headline_indices,
|
||||
description: keep.description,
|
||||
},
|
||||
},
|
||||
body.notes || body.comment || null
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: `Acción desconocida: ${action}` },
|
||||
@@ -96,7 +145,7 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, creative: result });
|
||||
return NextResponse.json({ ok: true, ...((result && result.creative) ? result : { creative: result }) });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ export async function POST(req: NextRequest) {
|
||||
const row = {
|
||||
tenant_id: ctx.tenantId,
|
||||
title: title.slice(0, 300),
|
||||
status: 'draft',
|
||||
status: 'borrador',
|
||||
channel: 'meta_ads',
|
||||
format: mime.startsWith('video/') ? 'video' : 'image',
|
||||
created_by: user.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
|
||||
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
|
||||
import { normalizeMetaAdCopyInput } from '@/lib/meta-ad-copy';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -50,7 +51,18 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const toInsert = items.map((c: any) => {
|
||||
const meta = { ...(c.meta || {}) };
|
||||
const copyNorm = normalizeMetaAdCopyInput({
|
||||
primary_text: c.primary_text,
|
||||
primary_texts: c.primary_texts,
|
||||
headline: c.headline,
|
||||
headlines: c.headlines,
|
||||
description: c.description,
|
||||
hook: c.hook,
|
||||
support: c.support,
|
||||
cta: c.cta,
|
||||
meta: c.meta || {},
|
||||
});
|
||||
const meta = { ...copyNorm.meta };
|
||||
if (c.composition_notes) {
|
||||
meta.composition_notes = c.composition_notes;
|
||||
}
|
||||
@@ -64,9 +76,10 @@ export async function POST(req: NextRequest) {
|
||||
meta_ad_id: c.meta_ad_id || null,
|
||||
format: c.format || 'estatico',
|
||||
angle: c.angle || null,
|
||||
hook: c.hook || null,
|
||||
primary_text: c.primary_text || null,
|
||||
headline: c.headline || null,
|
||||
hook: copyNorm.hook || c.hook || null,
|
||||
primary_text: copyNorm.primary_text,
|
||||
headline: copyNorm.headline,
|
||||
description: copyNorm.description,
|
||||
cta: c.cta || null,
|
||||
asset_url: c.asset_url || null,
|
||||
thumbnail_url: c.thumbnail_url || null,
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
|
||||
import ClientCreativeStylePanel from '@/components/ClientCreativeStylePanel';
|
||||
import { readMetaAdCopyBundle } from '@/lib/meta-ad-copy';
|
||||
import { REGEN_PRESETS } from '@/lib/creatives-regen';
|
||||
|
||||
export type CreativeFormat = 'estatico' | 'carrusel' | 'video';
|
||||
|
||||
@@ -43,6 +45,7 @@ export type CreativeItem = {
|
||||
hook?: string | null;
|
||||
primary_text?: string | null;
|
||||
headline?: string | null;
|
||||
description?: string | null;
|
||||
cta?: string | null;
|
||||
asset_url?: string | null;
|
||||
thumbnail_url?: string | null;
|
||||
@@ -94,6 +97,7 @@ export type GenerationRequest = {
|
||||
|
||||
const KANBAN_COLUMNS = [
|
||||
{ id: 'borrador', label: 'Borrador', color: 'border-amber-400 bg-amber-50/30' },
|
||||
{ id: 'revision_agencia', label: 'Revisión agencia', color: 'border-violet-400 bg-violet-50/30' },
|
||||
{ id: 'pendiente_aprobacion', label: 'Pendiente Cliente', color: 'border-blue-400 bg-blue-50/30' },
|
||||
{ id: 'aprobada', label: 'Aprobada', color: 'border-emerald-400 bg-emerald-50/30' },
|
||||
{ id: 'publicada', label: 'Publicada Meta', color: 'border-indigo-400 bg-indigo-50/30' },
|
||||
@@ -149,6 +153,18 @@ export default function AgencyCreativesView({
|
||||
const [rejectAutoRegenerate, setRejectAutoRegenerate] = useState(true);
|
||||
const [isRejecting, setIsRejecting] = useState(false);
|
||||
|
||||
// Regeneración personalizada por capas
|
||||
const [keepImage, setKeepImage] = useState(true);
|
||||
const [keepHook, setKeepHook] = useState(true);
|
||||
const [keepSupport, setKeepSupport] = useState(true);
|
||||
const [hookOverride, setHookOverride] = useState('');
|
||||
const [supportOverride, setSupportOverride] = useState('');
|
||||
const [keepPrimaries, setKeepPrimaries] = useState<number[]>([]);
|
||||
const [keepHeadlines, setKeepHeadlines] = useState<number[]>([]);
|
||||
const [keepDescription, setKeepDescription] = useState(false);
|
||||
const [copyV2Notes, setCopyV2Notes] = useState('');
|
||||
const [copyV2Busy, setCopyV2Busy] = useState(false);
|
||||
|
||||
const handleSaveInline = async (creativeId: string, alsoRecompose = false) => {
|
||||
setSavingInline(true);
|
||||
try {
|
||||
@@ -296,7 +312,23 @@ export default function AgencyCreativesView({
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 2. Acción: Enviar al cliente
|
||||
// 2a. Validación interna agencia
|
||||
const handleAgencyReview = async (creative: CreativeItem) => {
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${creative.id}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'agency_review' }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Error al marcar revisión');
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 2b. Enviar al cliente (aviso + pendiente_aprobacion)
|
||||
const handleSendToClient = async (creative: CreativeItem) => {
|
||||
if (!creative.asset_final) {
|
||||
alert('Solo se puede enviar al cliente si cuenta con imagen final compuesta (asset_final=true). Por favor sube la imagen final primero.');
|
||||
@@ -446,24 +478,106 @@ export default function AgencyCreativesView({
|
||||
// 7. Ver detalle / eventos
|
||||
const handleOpenDetail = async (c: CreativeItem) => {
|
||||
setDetailCreative(c);
|
||||
setKeepImage(true);
|
||||
setKeepHook(true);
|
||||
setKeepSupport(true);
|
||||
setHookOverride('');
|
||||
setSupportOverride('');
|
||||
setKeepPrimaries([]);
|
||||
setKeepHeadlines([]);
|
||||
setKeepDescription(false);
|
||||
setCopyV2Notes('');
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${c.id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDetailEvents(data.events || []);
|
||||
if (data.creative) setDetailCreative(data.creative);
|
||||
}
|
||||
} catch {
|
||||
setDetailEvents([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleKeepIndex = (
|
||||
list: number[],
|
||||
setList: (n: number[]) => void,
|
||||
index: number
|
||||
) => {
|
||||
setList(
|
||||
list.includes(index) ? list.filter((i) => i !== index) : [...list, index].sort()
|
||||
);
|
||||
};
|
||||
|
||||
const applyRegenPreset = (presetId: string) => {
|
||||
if (!detailCreative) return;
|
||||
const feed = readMetaAdCopyBundle(detailCreative);
|
||||
const preset = REGEN_PRESETS.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
const sel = preset.apply(feed);
|
||||
setKeepImage(sel.layers.image);
|
||||
setKeepHook(sel.layers.hook);
|
||||
setKeepSupport(sel.layers.support);
|
||||
setKeepPrimaries(sel.feed_keep.primary_indices);
|
||||
setKeepHeadlines(sel.feed_keep.headline_indices);
|
||||
setKeepDescription(sel.feed_keep.description);
|
||||
setHookOverride('');
|
||||
setSupportOverride('');
|
||||
};
|
||||
|
||||
const handlePartialV2 = async () => {
|
||||
if (!detailCreative) return;
|
||||
setCopyV2Busy(true);
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${detailCreative.id}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'partial_v2',
|
||||
layers: {
|
||||
image: keepImage,
|
||||
hook: keepHook,
|
||||
support: keepSupport,
|
||||
},
|
||||
hook_override: !keepHook ? hookOverride.trim() || null : hookOverride.trim() || null,
|
||||
support_override: !keepSupport
|
||||
? supportOverride.trim() || null
|
||||
: supportOverride.trim() || null,
|
||||
keep: {
|
||||
primary_indices: keepPrimaries,
|
||||
headline_indices: keepHeadlines,
|
||||
description: keepDescription,
|
||||
},
|
||||
notes: copyV2Notes.trim() || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Error al pedir regeneración');
|
||||
await loadData();
|
||||
const lines = (data.plan?.summary || data.summary || []) as string[];
|
||||
const mode = data.mode || '';
|
||||
alert(
|
||||
[
|
||||
mode === 'canva_only'
|
||||
? 'Re-montaje Canva enviado (misma imagen, nuevo texto en pieza).'
|
||||
: 'Regeneración personalizada pedida.',
|
||||
...(lines.length ? lines : []),
|
||||
].join('\n')
|
||||
);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setCopyV2Busy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Columnas Kanban
|
||||
const kanbanGroups = useMemo(() => {
|
||||
const groups: Record<string, CreativeItem[]> = {};
|
||||
for (const col of KANBAN_COLUMNS) groups[col.id] = [];
|
||||
|
||||
for (const c of creatives) {
|
||||
let st = c.status;
|
||||
let st = c.status as string;
|
||||
if (st === 'draft') st = 'borrador';
|
||||
if (st === 'in_review') st = 'pendiente_aprobacion';
|
||||
if (st === 'approved') st = 'aprobada';
|
||||
@@ -899,14 +1013,24 @@ export default function AgencyCreativesView({
|
||||
{c.asset_final ? 'Cambiar final' : 'Subir final'}
|
||||
</button>
|
||||
|
||||
{/* Botón Enviar al Cliente (solo si está en borrador o pendiente) */}
|
||||
{/* Validación agencia + enviar al cliente */}
|
||||
{col.id === 'borrador' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAgencyReview(c)}
|
||||
title="Marcar como revisada por la agencia (antes de avisar al cliente)"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 rounded-lg border border-violet-200 bg-violet-50 py-1.5 text-[11px] font-semibold text-violet-800 hover:bg-violet-100 transition-colors shadow-2xs"
|
||||
>
|
||||
Rev. agencia
|
||||
</button>
|
||||
) : null}
|
||||
{col.id === 'borrador' || col.id === 'revision_agencia' ? (
|
||||
<button
|
||||
onClick={() => handleSendToClient(c)}
|
||||
disabled={!c.asset_final}
|
||||
title={
|
||||
c.asset_final
|
||||
? 'Enviar a revisión del cliente'
|
||||
? 'Enviar a validación del cliente (dispara aviso)'
|
||||
: 'Debes subir la imagen final compuesta antes de enviar al cliente'
|
||||
}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1 rounded-lg py-1.5 text-[11px] font-semibold transition-colors shadow-2xs ${
|
||||
@@ -936,7 +1060,9 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
|
||||
{/* Segunda fila: Re-componer en Canva (1 clic) y Rechazar con feedback */}
|
||||
{(col.id === 'borrador' || col.id === 'pendiente_aprobacion') && (
|
||||
{(col.id === 'borrador' ||
|
||||
col.id === 'revision_agencia' ||
|
||||
col.id === 'pendiente_aprobacion') && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(c.meta?.raw_image_url || c.asset_url) ? (
|
||||
<button
|
||||
@@ -1634,20 +1760,209 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-xl bg-gray-50 p-3 border border-gray-200 space-y-1">
|
||||
<span className="text-[10px] uppercase font-bold text-gray-400">Titular</span>
|
||||
<p className="text-sm font-semibold text-gray-900">{detailCreative.headline || '—'}</p>
|
||||
{detailCreative.primary_text ? (
|
||||
<p className="text-xs text-gray-600 pt-1">{detailCreative.primary_text}</p>
|
||||
) : null}
|
||||
{detailCreative.hook ? (
|
||||
<p className="text-xs text-gray-700 pt-1"><strong>Hook:</strong> {detailCreative.hook}</p>
|
||||
) : null}
|
||||
{(() => {
|
||||
const copy = readMetaAdCopyBundle(detailCreative);
|
||||
const primaries = copy.primary_texts.filter(Boolean);
|
||||
const heads = copy.headlines.filter(Boolean);
|
||||
const primaryList = primaries.length
|
||||
? primaries
|
||||
: detailCreative.primary_text
|
||||
? [detailCreative.primary_text]
|
||||
: [];
|
||||
const headList = heads.length
|
||||
? heads
|
||||
: detailCreative.headline
|
||||
? [detailCreative.headline]
|
||||
: [];
|
||||
const desc = copy.description || detailCreative.description || '';
|
||||
return (
|
||||
<div className="rounded-xl bg-gray-50 p-3 border border-gray-200 space-y-4">
|
||||
<div className="space-y-3">
|
||||
<span className="text-[10px] uppercase font-bold text-violet-700 tracking-wider">
|
||||
Regeneración personalizada
|
||||
</span>
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Atajos o elige capa a capa. ✓ = conservar · sin ✓ = regenerar.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{REGEN_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
title={p.hint}
|
||||
onClick={() => applyRegenPreset(p.id)}
|
||||
className="rounded-lg border border-violet-200 bg-violet-50 px-2 py-1 text-[10px] font-semibold text-violet-800 hover:bg-violet-100"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 rounded-lg border border-gray-200 bg-white p-2.5">
|
||||
<label className="flex items-center gap-2 text-xs text-gray-800 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepImage}
|
||||
onChange={(e) => setKeepImage(e.target.checked)}
|
||||
/>
|
||||
Conservar imagen (foto Freepik)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-gray-800 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepHook}
|
||||
onChange={(e) => setKeepHook(e.target.checked)}
|
||||
/>
|
||||
Conservar título en imagen (hook)
|
||||
</label>
|
||||
{!keepHook ? (
|
||||
<input
|
||||
value={hookOverride}
|
||||
onChange={(e) => setHookOverride(e.target.value)}
|
||||
placeholder="Nuevo hook manual (vacío = lo escribe la IA)"
|
||||
className="ml-6 rounded-lg border border-gray-200 px-2 py-1.5 text-xs"
|
||||
/>
|
||||
) : null}
|
||||
<label className="flex items-center gap-2 text-xs text-gray-800 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepSupport}
|
||||
onChange={(e) => setKeepSupport(e.target.checked)}
|
||||
/>
|
||||
Conservar subtítulo en imagen (support)
|
||||
</label>
|
||||
{!keepSupport ? (
|
||||
<input
|
||||
value={supportOverride}
|
||||
onChange={(e) => setSupportOverride(e.target.value)}
|
||||
placeholder="Nuevo support manual (vacío = IA)"
|
||||
className="ml-6 rounded-lg border border-gray-200 px-2 py-1.5 text-xs"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-lg bg-violet-50/80 border border-violet-100 p-2 space-y-1">
|
||||
<p className="text-[10px] font-bold uppercase text-violet-700">
|
||||
Ahora en pieza
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
Hook: {copy.hook || detailCreative.hook || '—'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
Support: {copy.support || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-200 pt-3 space-y-3">
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-bold text-sky-700 tracking-wider">
|
||||
Copy Meta (acompaña) · ≤120 / ≤30 · emojis
|
||||
</span>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
Marca variantes a conservar. Sin marcar = v2 de ese slot.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-bold text-gray-400">
|
||||
Primary text ({primaryList.length}/5 · ≤120)
|
||||
</span>
|
||||
<ul className="mt-1 space-y-1.5">
|
||||
{primaryList.map((p, i) => (
|
||||
<li key={i} className="flex gap-2 items-start text-xs text-gray-600">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
checked={keepPrimaries.includes(i)}
|
||||
onChange={() =>
|
||||
toggleKeepIndex(keepPrimaries, setKeepPrimaries, i)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-semibold text-gray-800">#{i + 1}</span>{' '}
|
||||
{p}{' '}
|
||||
<span className="text-[10px] text-gray-400">({p.length})</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{!primaryList.length ? (
|
||||
<li className="text-xs text-gray-400">Sin primary aún</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-bold text-gray-400">
|
||||
Headlines ({headList.length}/5 · ≤30)
|
||||
</span>
|
||||
<ul className="mt-1 space-y-1.5">
|
||||
{headList.map((h, i) => (
|
||||
<li key={i} className="flex gap-2 items-start text-sm">
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={keepHeadlines.includes(i)}
|
||||
onChange={() =>
|
||||
toggleKeepIndex(keepHeadlines, setKeepHeadlines, i)
|
||||
}
|
||||
/>
|
||||
<span className="font-semibold text-gray-900">
|
||||
#{i + 1} {h}{' '}
|
||||
<span className="text-[10px] font-normal text-gray-400">
|
||||
({h.length})
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
{!headList.length ? (
|
||||
<li className="text-xs text-gray-400">Sin headlines aún</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1"
|
||||
checked={keepDescription}
|
||||
onChange={(e) => setKeepDescription(e.target.checked)}
|
||||
disabled={!desc}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-bold text-gray-400">
|
||||
Description (conservar)
|
||||
</span>
|
||||
<p className="text-xs text-gray-700 whitespace-pre-line mt-0.5">
|
||||
{desc || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-2 pt-1">
|
||||
<textarea
|
||||
value={copyV2Notes}
|
||||
onChange={(e) => setCopyV2Notes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Notas para la v2 (opcional): tono, ángulo, qué evitar…"
|
||||
className="w-full rounded-lg border border-gray-200 px-2.5 py-2 text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={copyV2Busy}
|
||||
onClick={() => void handlePartialV2()}
|
||||
className="w-full rounded-xl bg-sky-600 px-3 py-2 text-xs font-bold text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
{copyV2Busy
|
||||
? 'Aplicando…'
|
||||
: 'Aplicar regeneración personalizada'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{detailCreative.angle ? (
|
||||
<p className="text-xs text-purple-700 pt-0.5"><strong>Ángulo:</strong> {detailCreative.angle}</p>
|
||||
<p className="text-xs text-purple-700"><strong>Ángulo:</strong> {detailCreative.angle}</p>
|
||||
) : null}
|
||||
{detailCreative.meta?.composition_notes ? (
|
||||
<div className="mt-2 rounded-lg bg-amber-50 p-2.5 border border-amber-200 text-xs text-amber-950">
|
||||
<div className="rounded-lg bg-amber-50 p-2.5 border border-amber-200 text-xs text-amber-950">
|
||||
<span className="font-bold text-[10px] uppercase tracking-wider block text-amber-900">Montaje</span>
|
||||
<p className="whitespace-pre-line text-amber-900 text-[11px] mt-0.5 leading-relaxed">
|
||||
{detailCreative.meta.composition_notes}
|
||||
@@ -1655,6 +1970,8 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-bold text-gray-700 uppercase tracking-wider mb-2">
|
||||
|
||||
@@ -826,7 +826,7 @@ export default function ClientCreativeStylePanel({
|
||||
3. Cómo habla el cliente (Voz, Tono y Reglas de Copywriting)
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
Reglas de estilo verbal que Claude sigue al redactar hooks, titulares y primary_text.
|
||||
Reglas para Claude: Meta pide 5 primary_text (≤120), 5 headline (≤30) y 1 description (info + bullets + CTA).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ export default function ClientCreativesView({
|
||||
icon: Flame,
|
||||
},
|
||||
rechazada: {
|
||||
label: 'Rechazada por cliente',
|
||||
label: 'Cambios solicitados',
|
||||
badge: 'bg-red-100 text-red-800 border-red-200',
|
||||
icon: XCircle,
|
||||
},
|
||||
@@ -581,7 +581,7 @@ export default function ClientCreativesView({
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
pendiente_aprobacion: {
|
||||
label: 'Pendiente de aprobación',
|
||||
label: 'Pendiente de tu validación',
|
||||
badge: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
icon: Clock,
|
||||
},
|
||||
|
||||
@@ -30,6 +30,14 @@ import {
|
||||
scheduledAtFromPiece,
|
||||
} from '@/lib/calendar-documents';
|
||||
import { captionHasProductionNoise } from '@/lib/publish-caption';
|
||||
import {
|
||||
AGENCY_STATUS_ORDER,
|
||||
APPROVAL_COLOR,
|
||||
APPROVAL_LABEL,
|
||||
canSendToClient,
|
||||
clientCanDecide as clientCanDecideStatus,
|
||||
normalizeApprovalStatus,
|
||||
} from '@/lib/approval-status';
|
||||
|
||||
type CalendarOption = {
|
||||
id: string;
|
||||
@@ -75,23 +83,8 @@ type PipelineItem = {
|
||||
calendar_piece_id?: string | null;
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
propuesta: 'Propuesta',
|
||||
en_diseno: 'En diseño',
|
||||
revision_interna: 'Revisión interna',
|
||||
aprobado: 'Aprobado',
|
||||
rechazado: 'Rechazado',
|
||||
publicado: 'Publicado',
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
propuesta: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
en_diseno: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||
revision_interna: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
aprobado: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
rechazado: 'bg-red-100 text-red-700 border-red-200',
|
||||
publicado: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
};
|
||||
const STATUS_LABEL = APPROVAL_LABEL;
|
||||
const STATUS_COLOR = APPROVAL_COLOR;
|
||||
|
||||
const FORMAT_CONFIG: Record<string, { label: string; badgeCls: string }> = {
|
||||
post: { label: 'Post', badgeCls: 'bg-blue-50 text-blue-700 border-blue-200' },
|
||||
@@ -100,15 +93,6 @@ const FORMAT_CONFIG: Record<string, { label: string; badgeCls: string }> = {
|
||||
story: { label: 'Story', badgeCls: 'bg-amber-50 text-amber-700 border-amber-200' },
|
||||
};
|
||||
|
||||
const AGENCY_STATUS_ORDER = [
|
||||
'propuesta',
|
||||
'en_diseno',
|
||||
'revision_interna',
|
||||
'aprobado',
|
||||
'rechazado',
|
||||
'publicado',
|
||||
];
|
||||
|
||||
function mediaUrl(path: string | null) {
|
||||
if (!path) return null;
|
||||
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
|
||||
@@ -222,7 +206,13 @@ export default function CreativePipelineBoard() {
|
||||
try {
|
||||
const res = await fetch('/api/creative-pipeline');
|
||||
const data = await res.json();
|
||||
setItems(data.items ?? []);
|
||||
const raw = (data.items ?? []) as PipelineItem[];
|
||||
setItems(
|
||||
raw.map((it) => ({
|
||||
...it,
|
||||
status: normalizeApprovalStatus(it.status),
|
||||
}))
|
||||
);
|
||||
setIsAgency(Boolean(data.isAgency));
|
||||
setWarning(data.warning ?? null);
|
||||
} finally {
|
||||
@@ -758,7 +748,7 @@ export default function CreativePipelineBoard() {
|
||||
description={
|
||||
isAgency
|
||||
? 'Producción editorial. Publica o programa IG/FB desde aquí (vista agencia).'
|
||||
: 'Revisa y aprueba las creatividades propuestas por 4Ventures.'
|
||||
: 'Cuando 4Ventures te envíe piezas, apruébalas o pide cambios aquí.'
|
||||
}
|
||||
actions={
|
||||
isAgency ? (
|
||||
@@ -1271,7 +1261,8 @@ function PieceCard({
|
||||
};
|
||||
const igUrl = getInstagramUrl(item.notes);
|
||||
|
||||
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
||||
const status = normalizeApprovalStatus(item.status);
|
||||
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
||||
const when =
|
||||
item.published_at || item.scheduled_at || item.created_at;
|
||||
|
||||
@@ -1437,10 +1428,10 @@ function PieceCard({
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-flex w-fit rounded-full px-2 py-0.5 text-xs font-semibold border ${
|
||||
STATUS_COLOR[item.status] || 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
STATUS_COLOR[status] || 'bg-gray-100 text-gray-700 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABEL[item.status] || item.status}
|
||||
{STATUS_LABEL[status] || status}
|
||||
</span>
|
||||
|
||||
{scheduleLabel(item.scheduled_at) ? (
|
||||
@@ -1485,32 +1476,45 @@ function PieceCard({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
<div className="mt-auto pt-2 space-y-1.5">
|
||||
{isAgency ? (
|
||||
<select
|
||||
value={item.status}
|
||||
disabled={busy}
|
||||
onChange={(e) => onStatus(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium"
|
||||
>
|
||||
{AGENCY_STATUS_ORDER.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABEL[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
value={status}
|
||||
disabled={busy}
|
||||
onChange={(e) => onStatus(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-medium"
|
||||
>
|
||||
{AGENCY_STATUS_ORDER.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{STATUS_LABEL[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{canSendToClient(status) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('pendiente_aprobacion')}
|
||||
className="w-full inline-flex items-center justify-center gap-1 rounded-lg bg-sky-600 px-2 py-1.5 text-xs font-semibold text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-3 w-3" />
|
||||
Enviar al cliente
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : clientCanDecide ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('aprobado')}
|
||||
onClick={() => onStatus('aprobada')}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
|
||||
>
|
||||
Aprobar
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('rechazado')}
|
||||
onClick={() => onStatus('rechazada')}
|
||||
className="flex-1 rounded-lg bg-red-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Rechazar
|
||||
@@ -1518,11 +1522,13 @@ function PieceCard({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
{item.status === 'aprobado'
|
||||
{status === 'aprobada'
|
||||
? 'Aprobada por ti'
|
||||
: item.status === 'publicado'
|
||||
: status === 'publicada'
|
||||
? 'Publicado en redes'
|
||||
: 'Pendiente del equipo de 4Ventures'}
|
||||
: status === 'rechazada'
|
||||
? 'Rechazada'
|
||||
: 'Pendiente del equipo de 4Ventures'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1616,7 +1622,8 @@ function DetailModal({
|
||||
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
};
|
||||
|
||||
const clientCanDecide = !isAgency && item.status === 'revision_interna';
|
||||
const status = normalizeApprovalStatus(item.status);
|
||||
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
|
||||
|
||||
function submitSchedule() {
|
||||
const channels: PublishChannel[] = [];
|
||||
@@ -1726,10 +1733,10 @@ function DetailModal({
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold border ${
|
||||
STATUS_COLOR[item.status] || 'bg-gray-100 text-gray-700'
|
||||
STATUS_COLOR[status] || 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABEL[item.status] || item.status}
|
||||
{STATUS_LABEL[status] || status}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -1994,9 +2001,11 @@ function DetailModal({
|
||||
<div className="mt-6 border-t border-gray-100 pt-4">
|
||||
{isAgency ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-gray-600">Cambiar estado (Agencia):</label>
|
||||
<label className="block text-xs font-semibold text-gray-600">
|
||||
Estado (agencia → luego cliente)
|
||||
</label>
|
||||
<select
|
||||
value={item.status}
|
||||
value={status}
|
||||
disabled={busy}
|
||||
onChange={(e) => onStatus(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium"
|
||||
@@ -2007,21 +2016,34 @@ function DetailModal({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{canSendToClient(status) ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('pendiente_aprobacion')}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg bg-sky-600 px-3 py-2 text-xs font-bold text-white hover:bg-sky-700 disabled:opacity-50"
|
||||
>
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
Enviar al cliente para validar
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : clientCanDecide ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-gray-600">Tu decisión como cliente:</p>
|
||||
<p className="text-xs font-semibold text-gray-600">
|
||||
Tu decisión como cliente:
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('aprobado')}
|
||||
onClick={() => onStatus('aprobada')}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-3 py-2 text-xs font-bold text-white hover:bg-emerald-700"
|
||||
>
|
||||
Aprobar pieza
|
||||
</button>
|
||||
<button
|
||||
disabled={busy}
|
||||
onClick={() => onStatus('rechazado')}
|
||||
onClick={() => onStatus('rechazada')}
|
||||
className="flex-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-bold text-white hover:bg-red-700"
|
||||
>
|
||||
Solicitar cambios
|
||||
@@ -2030,11 +2052,13 @@ function DetailModal({
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-xs text-gray-500">
|
||||
{item.status === 'aprobado'
|
||||
? '✓ Esta pieza fue aprobada por el cliente.'
|
||||
: item.status === 'publicado'
|
||||
? '✓ Esta pieza está publicada y archivada.'
|
||||
: 'En proceso con el equipo de 4Ventures.'}
|
||||
{status === 'aprobada'
|
||||
? 'Esta pieza fue aprobada por el cliente.'
|
||||
: status === 'publicada'
|
||||
? 'Esta pieza está publicada.'
|
||||
: status === 'rechazada'
|
||||
? 'Rechazada — la agencia ajustará.'
|
||||
: 'En proceso con el equipo de 4Ventures.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,11 @@ import CreativePlacementPreview, {
|
||||
type CreativePreviewData,
|
||||
} from '@/components/CreativePlacementPreview';
|
||||
import { PageHeader } from '@/components/ui';
|
||||
import {
|
||||
AGENCY_STATUS_ORDER,
|
||||
APPROVAL_LABEL,
|
||||
normalizeApprovalStatus,
|
||||
} from '@/lib/approval-status';
|
||||
|
||||
export type Creative = CreativePreviewData & {
|
||||
id: string;
|
||||
@@ -29,31 +34,13 @@ export type Creative = CreativePreviewData & {
|
||||
caption?: string | null;
|
||||
};
|
||||
|
||||
const STATUSES = [
|
||||
{ id: 'draft', label: 'Propuesta' },
|
||||
{ id: 'in_review', label: 'Revisión interna' },
|
||||
{ id: 'approved', label: 'Aprobado' },
|
||||
{ id: 'rejected', label: 'Rechazado' },
|
||||
{ id: 'published', label: 'Publicado' },
|
||||
] as const;
|
||||
|
||||
const EDITORIAL_STATUS: Record<string, string> = {
|
||||
propuesta: 'draft',
|
||||
en_diseno: 'draft',
|
||||
revision_interna: 'in_review',
|
||||
aprobado: 'approved',
|
||||
rechazado: 'rejected',
|
||||
publicado: 'published',
|
||||
draft: 'draft',
|
||||
in_review: 'in_review',
|
||||
approved: 'approved',
|
||||
rejected: 'rejected',
|
||||
published: 'published',
|
||||
};
|
||||
const STATUSES = AGENCY_STATUS_ORDER.map((id) => ({
|
||||
id,
|
||||
label: APPROVAL_LABEL[id],
|
||||
}));
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const mapped = EDITORIAL_STATUS[status] || status;
|
||||
return STATUSES.find((s) => s.id === mapped)?.label || status;
|
||||
return APPROVAL_LABEL[normalizeApprovalStatus(status)] || status;
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
@@ -173,7 +160,7 @@ export default function CreativesPipeline() {
|
||||
return {
|
||||
id: it.id,
|
||||
title: it.title,
|
||||
status: EDITORIAL_STATUS[it.status] || it.status,
|
||||
status: normalizeApprovalStatus(it.status),
|
||||
source: 'editorial' as const,
|
||||
channel: 'instagram',
|
||||
placement: it.format === 'story' ? 'stories' : 'feed',
|
||||
@@ -227,7 +214,7 @@ export default function CreativesPipeline() {
|
||||
)
|
||||
);
|
||||
}
|
||||
const mapped = EDITORIAL_STATUS[c.status] || c.status;
|
||||
const mapped = normalizeApprovalStatus(c.status);
|
||||
return mapped === filter || c.status === filter;
|
||||
});
|
||||
}, [creatives, filter, monthFilter]);
|
||||
@@ -297,26 +284,11 @@ export default function CreativesPipeline() {
|
||||
const item = creatives.find((c) => c.id === id);
|
||||
const endpoint =
|
||||
item?.source === 'editorial' ? '/api/creative-pipeline' : '/api/creatives';
|
||||
const editorialStatus =
|
||||
item?.source === 'editorial'
|
||||
? status === 'draft'
|
||||
? 'propuesta'
|
||||
: status === 'in_review'
|
||||
? 'revision_interna'
|
||||
: status === 'approved'
|
||||
? 'aprobado'
|
||||
: status === 'rejected'
|
||||
? 'rechazado'
|
||||
: status === 'published'
|
||||
? 'publicado'
|
||||
: status
|
||||
: status === 'published'
|
||||
? 'approved'
|
||||
: status;
|
||||
const canonical = normalizeApprovalStatus(status);
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status: editorialStatus }),
|
||||
body: JSON.stringify({ id, status: canonical }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) setErr(data.error ?? 'No se pudo actualizar');
|
||||
@@ -735,7 +707,7 @@ export default function CreativesPipeline() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="border border-gray-200 px-2 py-1 font-mono text-[10px] tracking-wide uppercase text-gray-600">
|
||||
{STATUSES.find((s) => s.id === (EDITORIAL_STATUS[c.status] || c.status))?.label ||
|
||||
{STATUSES.find((s) => s.id === (normalizeApprovalStatus(c.status)))?.label ||
|
||||
statusLabel(c.status)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -768,7 +740,7 @@ export default function CreativesPipeline() {
|
||||
<>
|
||||
<select
|
||||
className="flex-1 text-[11px] rounded-lg bg-gray-900 border border-gray-200 text-gray-600 px-2 py-1.5"
|
||||
value={EDITORIAL_STATUS[c.status] || c.status}
|
||||
value={normalizeApprovalStatus(c.status)}
|
||||
disabled={busyId === c.id}
|
||||
onChange={(e) => setStatus(c.id, e.target.value)}
|
||||
>
|
||||
@@ -788,12 +760,12 @@ export default function CreativesPipeline() {
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (EDITORIAL_STATUS[c.status] || c.status) === 'in_review' ? (
|
||||
) : (normalizeApprovalStatus(c.status)) === 'pendiente_aprobacion' ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === c.id}
|
||||
onClick={() => setStatus(c.id, 'approved')}
|
||||
onClick={() => setStatus(c.id, 'aprobada')}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-2 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Aprobar
|
||||
@@ -801,7 +773,7 @@ export default function CreativesPipeline() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === c.id}
|
||||
onClick={() => setStatus(c.id, 'rejected')}
|
||||
onClick={() => setStatus(c.id, 'rechazada')}
|
||||
className="flex-1 rounded-lg bg-red-600 px-2 py-1.5 text-xs font-medium text-white"
|
||||
>
|
||||
Rechazar
|
||||
@@ -809,9 +781,9 @@ export default function CreativesPipeline() {
|
||||
</>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">
|
||||
{(EDITORIAL_STATUS[c.status] || c.status) === 'approved'
|
||||
{(normalizeApprovalStatus(c.status)) === 'aprobada'
|
||||
? 'Aprobada por ti'
|
||||
: (EDITORIAL_STATUS[c.status] || c.status) === 'published'
|
||||
: (normalizeApprovalStatus(c.status)) === 'publicada'
|
||||
? 'Publicado'
|
||||
: 'Pendiente del equipo de 4Ventures'}
|
||||
</p>
|
||||
@@ -826,7 +798,9 @@ export default function CreativesPipeline() {
|
||||
{!loading && creatives.length > 0 && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 pt-2">
|
||||
{STATUSES.map((col) => {
|
||||
const n = creatives.filter((c) => c.status === col.id).length;
|
||||
const n = creatives.filter(
|
||||
(c) => normalizeApprovalStatus(c.status) === col.id
|
||||
).length;
|
||||
return (
|
||||
<div
|
||||
key={col.id}
|
||||
@@ -1008,13 +982,13 @@ export default function CreativesPipeline() {
|
||||
>
|
||||
Guardar cambios
|
||||
</button>
|
||||
) : (EDITORIAL_STATUS[selected.status] || selected.status) ===
|
||||
'in_review' ? (
|
||||
) : (normalizeApprovalStatus(selected.status)) ===
|
||||
'pendiente_aprobacion' ? (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={() => setStatus(selected.id, 'approved')}
|
||||
onClick={() => setStatus(selected.id, 'aprobada')}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-600 text-white"
|
||||
>
|
||||
Aprobar
|
||||
@@ -1022,7 +996,7 @@ export default function CreativesPipeline() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={busyId === selected.id}
|
||||
onClick={() => setStatus(selected.id, 'rejected')}
|
||||
onClick={() => setStatus(selected.id, 'rechazada')}
|
||||
className="px-4 py-2 rounded-xl text-xs font-semibold bg-red-600 text-white"
|
||||
>
|
||||
Rechazar
|
||||
|
||||
163
src/lib/approval-status.ts
Normal file
163
src/lib/approval-status.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Estados de aprobación unificados para Ads (`creatives`) y RRSS (`creative_pipeline`).
|
||||
*
|
||||
* Flujo:
|
||||
* borrador → revision_agencia → pendiente_aprobacion → aprobada | rechazada → publicada
|
||||
*
|
||||
* - borrador / revision_agencia: solo agencia
|
||||
* - pendiente_aprobacion: cliente puede aprobar/rechazar (+ aviso al enviar)
|
||||
* - aprobada: lista para publicar / programar
|
||||
* - Ads extras: fatigada, retirada
|
||||
*/
|
||||
|
||||
export const APPROVAL_STATUSES = [
|
||||
'borrador',
|
||||
'revision_agencia',
|
||||
'pendiente_aprobacion',
|
||||
'aprobada',
|
||||
'rechazada',
|
||||
'publicada',
|
||||
'fatigada',
|
||||
'retirada',
|
||||
] as const;
|
||||
|
||||
export type ApprovalStatus = (typeof APPROVAL_STATUSES)[number];
|
||||
|
||||
/** Estados del flujo editorial compartido (sin extras Ads). */
|
||||
export const SHARED_APPROVAL_STATUSES = [
|
||||
'borrador',
|
||||
'revision_agencia',
|
||||
'pendiente_aprobacion',
|
||||
'aprobada',
|
||||
'rechazada',
|
||||
'publicada',
|
||||
] as const;
|
||||
|
||||
export type SharedApprovalStatus = (typeof SHARED_APPROVAL_STATUSES)[number];
|
||||
|
||||
const SHARED_SET = new Set<string>(SHARED_APPROVAL_STATUSES);
|
||||
const ALL_SET = new Set<string>(APPROVAL_STATUSES);
|
||||
|
||||
/** Legacy RRSS → canónico */
|
||||
const LEGACY_TO_CANONICAL: Record<string, ApprovalStatus> = {
|
||||
propuesta: 'borrador',
|
||||
en_diseno: 'borrador',
|
||||
// Históricamente el cliente decidía aquí → era "pendiente cliente"
|
||||
revision_interna: 'pendiente_aprobacion',
|
||||
aprobado: 'aprobada',
|
||||
rechazado: 'rechazada',
|
||||
publicado: 'publicada',
|
||||
// Legacy Ads EN
|
||||
draft: 'borrador',
|
||||
in_review: 'pendiente_aprobacion',
|
||||
approved: 'aprobada',
|
||||
rejected: 'rechazada',
|
||||
published: 'publicada',
|
||||
};
|
||||
|
||||
export const APPROVAL_LABEL: Record<ApprovalStatus, string> = {
|
||||
borrador: 'Borrador',
|
||||
revision_agencia: 'Revisión agencia',
|
||||
pendiente_aprobacion: 'Pendiente cliente',
|
||||
aprobada: 'Aprobada',
|
||||
rechazada: 'Rechazada',
|
||||
publicada: 'Publicada',
|
||||
fatigada: 'Fatigada',
|
||||
retirada: 'Retirada',
|
||||
};
|
||||
|
||||
export const APPROVAL_COLOR: Record<ApprovalStatus, string> = {
|
||||
borrador: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||
revision_agencia: 'bg-violet-100 text-violet-800 border-violet-200',
|
||||
pendiente_aprobacion: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
aprobada: 'bg-emerald-100 text-emerald-700 border-emerald-200',
|
||||
rechazada: 'bg-red-100 text-red-700 border-red-200',
|
||||
publicada: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||
fatigada: 'bg-rose-100 text-rose-700 border-rose-200',
|
||||
retirada: 'bg-gray-100 text-gray-500 border-gray-200',
|
||||
};
|
||||
|
||||
/** Columnas kanban / filtros agencia (RRSS + Ads base). */
|
||||
export const AGENCY_STATUS_ORDER: SharedApprovalStatus[] = [
|
||||
'borrador',
|
||||
'revision_agencia',
|
||||
'pendiente_aprobacion',
|
||||
'aprobada',
|
||||
'rechazada',
|
||||
'publicada',
|
||||
];
|
||||
|
||||
/** Lo que el cliente puede ver. */
|
||||
export const CLIENT_VISIBLE_STATUSES: ApprovalStatus[] = [
|
||||
'pendiente_aprobacion',
|
||||
'aprobada',
|
||||
'rechazada',
|
||||
'publicada',
|
||||
'fatigada',
|
||||
'retirada',
|
||||
];
|
||||
|
||||
/** Cliente solo decide desde pendiente_aprobacion. */
|
||||
export const CLIENT_ALLOWED_TRANSITIONS: Record<
|
||||
string,
|
||||
SharedApprovalStatus[]
|
||||
> = {
|
||||
pendiente_aprobacion: ['aprobada', 'rechazada'],
|
||||
};
|
||||
|
||||
export function isApprovalStatus(v: unknown): v is ApprovalStatus {
|
||||
return typeof v === 'string' && ALL_SET.has(v);
|
||||
}
|
||||
|
||||
export function isSharedApprovalStatus(v: unknown): v is SharedApprovalStatus {
|
||||
return typeof v === 'string' && SHARED_SET.has(v);
|
||||
}
|
||||
|
||||
/** Normaliza legacy o canónico → canónico. Desconocido → borrador. */
|
||||
export function normalizeApprovalStatus(
|
||||
raw: string | null | undefined
|
||||
): ApprovalStatus {
|
||||
if (!raw) return 'borrador';
|
||||
if (ALL_SET.has(raw)) return raw as ApprovalStatus;
|
||||
const mapped = LEGACY_TO_CANONICAL[raw];
|
||||
if (mapped) return mapped;
|
||||
return 'borrador';
|
||||
}
|
||||
|
||||
export function approvalLabel(status: string): string {
|
||||
const n = normalizeApprovalStatus(status);
|
||||
return APPROVAL_LABEL[n] || status;
|
||||
}
|
||||
|
||||
export function approvalColor(status: string): string {
|
||||
const n = normalizeApprovalStatus(status);
|
||||
return APPROVAL_COLOR[n];
|
||||
}
|
||||
|
||||
export function clientCanDecide(status: string): boolean {
|
||||
return normalizeApprovalStatus(status) === 'pendiente_aprobacion';
|
||||
}
|
||||
|
||||
export function isAgencyOnlyStatus(status: string): boolean {
|
||||
const n = normalizeApprovalStatus(status);
|
||||
return n === 'borrador' || n === 'revision_agencia';
|
||||
}
|
||||
|
||||
/** Agencia puede enviar al cliente desde borrador o revisión agencia. */
|
||||
export function canSendToClient(status: string): boolean {
|
||||
const n = normalizeApprovalStatus(status);
|
||||
return n === 'borrador' || n === 'revision_agencia';
|
||||
}
|
||||
|
||||
/**
|
||||
* Acepta status de escritura: canónico o legacy (normalizado).
|
||||
* Devuelve canónico o null si no es válido para el dominio shared/pipeline.
|
||||
*/
|
||||
export function parseSharedStatusWrite(
|
||||
raw: unknown
|
||||
): SharedApprovalStatus | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const n = normalizeApprovalStatus(raw);
|
||||
if (SHARED_SET.has(n)) return n as SharedApprovalStatus;
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { metaAdCopySkillRules } from '@/lib/meta-ad-copy';
|
||||
|
||||
export type CreativeB2BPersona = {
|
||||
name: string;
|
||||
role: string;
|
||||
@@ -166,7 +168,7 @@ export const BPS_DEFAULT_CREATIVE_STYLE: ClientCreativeStyle = {
|
||||
language_and_pronoun:
|
||||
'Español de España, tú o usted según el público: tú en B2C, tú en B2B cuando hablamos a la persona, usted nunca.',
|
||||
copy_structure_rules:
|
||||
'Frases cortas. Un dato concreto vale más que un adjetivo. En primary_text: 2-4 frases, máx. 300 caracteres; la frase 1 amplía el hook, frases 2-3 dan un beneficio o dato concreto, frase final cierra con el CTA sin enlaces ni hashtags.',
|
||||
'Dos capas: (1) EN IMAGEN — hook + support para Canva; (2) ACOMPAÑA EN META — 5 primary_text ≤120, 5 headline ≤30, 1 description (info + bullets + CTA). No mezclar hook de imagen con primary_text del anuncio. Frases cortas; sin enlaces ni hashtags en primary/headline.',
|
||||
english_terms_policy:
|
||||
'Inglés en el copy: solo nombres propios de exámenes (Cambridge, Aptis, IELTS) o términos sin traducción natural (in-company). Nada de "boost", "unlock", "level up".',
|
||||
power_words: [
|
||||
@@ -238,7 +240,7 @@ export const BPS_DEFAULT_CREATIVE_STYLE: ClientCreativeStyle = {
|
||||
],
|
||||
competitors_never_mention: ['Twenix', 'Preply', 'Italki', 'Open English', 'What\'s Up!'],
|
||||
emoji_policy:
|
||||
'Prohibidos los emojis en creatividades de imagen. En copy (primary_text), como mucho uno al inicio y solo en B2C (nunca en B2B).',
|
||||
'Sin emojis en texto DENTRO de la imagen (hook/support). En copy que ACOMPAÑA (primary_text, headline, description) los emojis son OBLIGATORIOS en cada variante.',
|
||||
testimonials_policy:
|
||||
'Prohibidos testimonios inventados o con nombre y cargo de personas que no existen. Si no hay un caso real aprobado, no se usa testimonio.',
|
||||
},
|
||||
@@ -322,7 +324,8 @@ export function createEmptyCreativeStyle(clientName: string, slug: string): Clie
|
||||
tone: 'Profesional, directo y cercano.',
|
||||
posture: '',
|
||||
language_and_pronoun: 'Español de España, tratamiento de tú.',
|
||||
copy_structure_rules: 'Frases cortas y directas. Un dato concreto vale más que un adjetivo.',
|
||||
copy_structure_rules:
|
||||
'EN IMAGEN: hook + support. ACOMPAÑA META: 5 primary ≤120, 5 headline ≤30, 1 description. No mezclar capas.',
|
||||
english_terms_policy: '',
|
||||
power_words: [],
|
||||
allowed_ctas: ['Más información', 'Solicitar presupuesto', 'Reservar', 'Contactar', 'Registrarte'],
|
||||
@@ -341,7 +344,8 @@ export function createEmptyCreativeStyle(clientName: string, slug: string): Clie
|
||||
'Deformar o recrear el logo mediante IA.',
|
||||
],
|
||||
competitors_never_mention: [],
|
||||
emoji_policy: 'Sin emojis en imagen. En copy, máximo uno al inicio si aplica.',
|
||||
emoji_policy:
|
||||
'Sin emojis en imagen (hook/support). Emojis obligatorios en primary/headline/description que acompañan.',
|
||||
testimonials_policy: 'Solo casos reales aprobados por el cliente; nunca testimonios ficticios.',
|
||||
},
|
||||
visual: {
|
||||
@@ -646,5 +650,8 @@ export function compileClientSkillMarkdown(
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(metaAdCopySkillRules());
|
||||
lines.push('');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
287
src/lib/creatives-regen.ts
Normal file
287
src/lib/creatives-regen.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Regeneración personalizada por capas de una creatividad Ads.
|
||||
*
|
||||
* Capas independientes (conservar / regenerar):
|
||||
* 1. Imagen (Freepik / asset base)
|
||||
* 2. Hook — título DENTRO de la imagen
|
||||
* 3. Support — subtítulo DENTRO de la imagen
|
||||
* 4. Copy Meta que ACOMPAÑA (primary / headline / description, con keep parcial)
|
||||
*
|
||||
* Rutas de ejecución:
|
||||
* - Solo hook/support (y hay texto nuevo o AI) + misma imagen → Canva WF5 (recompose)
|
||||
* - Cualquier cambio de imagen o copy feed AI → generation_request (WF3+) con plan
|
||||
*/
|
||||
|
||||
import {
|
||||
feedCopyRegenPlan,
|
||||
normalizeKeepSelection,
|
||||
type FeedCopyKeepSelection,
|
||||
type MetaFeedCopy,
|
||||
} from '@/lib/meta-ad-copy';
|
||||
|
||||
export type LayerKeep = {
|
||||
/** Conservar asset / raw_image actual */
|
||||
image: boolean;
|
||||
/** Conservar hook (título en imagen) */
|
||||
hook: boolean;
|
||||
/** Conservar support (subtítulo en imagen) */
|
||||
support: boolean;
|
||||
};
|
||||
|
||||
export type CreativeRegenSelection = {
|
||||
layers: LayerKeep;
|
||||
/** Si regeneras hook/support, puedes fijar el texto nuevo (si vacío → IA) */
|
||||
hook_override?: string | null;
|
||||
support_override?: string | null;
|
||||
/** Keep parcial del copy que acompaña (Meta) */
|
||||
feed_keep: FeedCopyKeepSelection;
|
||||
};
|
||||
|
||||
export type CreativeRegenPlan = {
|
||||
selection: CreativeRegenSelection;
|
||||
regenerate: {
|
||||
image: boolean;
|
||||
hook: boolean;
|
||||
support: boolean;
|
||||
feed: boolean;
|
||||
};
|
||||
/** Texto on-image que debe quedar tras la operación */
|
||||
next_hook: string | null;
|
||||
next_support: string | null;
|
||||
feed_plan: ReturnType<typeof feedCopyRegenPlan>;
|
||||
/** Solo re-montar Canva, sin Freepik ni Claude copy */
|
||||
canva_only: boolean;
|
||||
/** Hace falta generation_request (Claude / Freepik) */
|
||||
needs_generation_request: boolean;
|
||||
/** Resumen humano */
|
||||
summary: string[];
|
||||
};
|
||||
|
||||
export function defaultLayerKeep(): LayerKeep {
|
||||
return { image: true, hook: true, support: true };
|
||||
}
|
||||
|
||||
export function defaultFeedKeepAll(feed: MetaFeedCopy): FeedCopyKeepSelection {
|
||||
return {
|
||||
primary_indices: feed.primary_texts
|
||||
.map((t, i) => (t ? i : -1))
|
||||
.filter((i) => i >= 0),
|
||||
headline_indices: feed.headlines
|
||||
.map((t, i) => (t ? i : -1))
|
||||
.filter((i) => i >= 0),
|
||||
description: Boolean(feed.description),
|
||||
};
|
||||
}
|
||||
|
||||
/** Presets de UX */
|
||||
export const REGEN_PRESETS: {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
apply: (feed: MetaFeedCopy) => CreativeRegenSelection;
|
||||
}[] = [
|
||||
{
|
||||
id: 'image_only',
|
||||
label: 'Solo nueva imagen',
|
||||
hint: 'Me gustan todos los textos; cambio la foto',
|
||||
apply: (feed) => ({
|
||||
layers: { image: false, hook: true, support: true },
|
||||
feed_keep: defaultFeedKeepAll(feed),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'hook_only',
|
||||
label: 'Solo título en imagen (hook)',
|
||||
hint: 'La foto y el subtítulo están bien',
|
||||
apply: (feed) => ({
|
||||
layers: { image: true, hook: false, support: true },
|
||||
feed_keep: defaultFeedKeepAll(feed),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'support_only',
|
||||
label: 'Solo subtítulo en imagen (support)',
|
||||
hint: 'La foto y el título están bien',
|
||||
apply: (feed) => ({
|
||||
layers: { image: true, hook: true, support: false },
|
||||
feed_keep: defaultFeedKeepAll(feed),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'on_image_text',
|
||||
label: 'Título + subtítulo en imagen',
|
||||
hint: 'Misma foto; nuevo texto en Canva',
|
||||
apply: (feed) => ({
|
||||
layers: { image: true, hook: false, support: false },
|
||||
feed_keep: defaultFeedKeepAll(feed),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'feed_only',
|
||||
label: 'Solo copy Meta (el que acompaña)',
|
||||
hint: 'Imagen y textos de pieza iguales; regeneras primary/headline/desc',
|
||||
apply: () => ({
|
||||
layers: { image: true, hook: true, support: true },
|
||||
feed_keep: {
|
||||
primary_indices: [],
|
||||
headline_indices: [],
|
||||
description: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function buildCreativeRegenPlan(
|
||||
current: {
|
||||
hook?: string | null;
|
||||
support?: string | null;
|
||||
feed: MetaFeedCopy;
|
||||
},
|
||||
selection: CreativeRegenSelection
|
||||
): CreativeRegenPlan {
|
||||
const layers = {
|
||||
image: selection.layers?.image !== false,
|
||||
hook: selection.layers?.hook !== false,
|
||||
support: selection.layers?.support !== false,
|
||||
};
|
||||
|
||||
const regenerate = {
|
||||
image: !layers.image,
|
||||
hook: !layers.hook,
|
||||
support: !layers.support,
|
||||
feed: false,
|
||||
};
|
||||
|
||||
const feed_keep = normalizeKeepSelection(selection.feed_keep);
|
||||
const feed_plan = feedCopyRegenPlan(current.feed, feed_keep);
|
||||
regenerate.feed = feed_plan.needsRegen;
|
||||
|
||||
const hookOverride = (selection.hook_override || '').trim();
|
||||
const supportOverride = (selection.support_override || '').trim();
|
||||
|
||||
let next_hook: string | null = (current.hook || '').trim() || null;
|
||||
let next_support: string | null = (current.support || '').trim() || null;
|
||||
|
||||
if (regenerate.hook) {
|
||||
next_hook = hookOverride || null; // null = IA
|
||||
} else if (hookOverride) {
|
||||
next_hook = hookOverride; // edición manual sin “regenerar”
|
||||
}
|
||||
|
||||
if (regenerate.support) {
|
||||
next_support = supportOverride || null;
|
||||
} else if (supportOverride) {
|
||||
next_support = supportOverride;
|
||||
}
|
||||
|
||||
const canva_only =
|
||||
!regenerate.image &&
|
||||
!regenerate.feed &&
|
||||
(regenerate.hook ||
|
||||
regenerate.support ||
|
||||
Boolean(hookOverride) ||
|
||||
Boolean(supportOverride)) &&
|
||||
// Canva-only solo si ya tenemos textos finales (override o keep)
|
||||
(layers.hook || Boolean(hookOverride)) &&
|
||||
(layers.support || Boolean(supportOverride) || next_support !== null);
|
||||
|
||||
// Si regeneramos hook/support sin override → hace falta IA (generation_request)
|
||||
const onImageNeedsAi =
|
||||
(regenerate.hook && !hookOverride) || (regenerate.support && !supportOverride);
|
||||
|
||||
const needs_generation_request =
|
||||
regenerate.image || regenerate.feed || onImageNeedsAi;
|
||||
|
||||
// canva_only real: cambios de texto en imagen con overrides (o keep) y misma imagen, sin AI
|
||||
const canva_only_strict =
|
||||
!needs_generation_request &&
|
||||
layers.image &&
|
||||
(Boolean(hookOverride) ||
|
||||
Boolean(supportOverride) ||
|
||||
regenerate.hook === false ||
|
||||
regenerate.support === false) &&
|
||||
(Boolean(hookOverride) || Boolean(supportOverride));
|
||||
|
||||
const summary: string[] = [];
|
||||
if (regenerate.image) summary.push('Nueva imagen (Freepik)');
|
||||
else summary.push('Conservar imagen');
|
||||
if (regenerate.hook) {
|
||||
summary.push(
|
||||
hookOverride
|
||||
? `Nuevo hook (manual): “${hookOverride.slice(0, 40)}”`
|
||||
: 'Nuevo hook (IA)'
|
||||
);
|
||||
} else summary.push('Conservar hook');
|
||||
if (regenerate.support) {
|
||||
summary.push(
|
||||
supportOverride
|
||||
? `Nuevo support (manual): “${supportOverride.slice(0, 40)}”`
|
||||
: 'Nuevo support (IA)'
|
||||
);
|
||||
} else summary.push('Conservar support');
|
||||
if (regenerate.feed) {
|
||||
summary.push(
|
||||
`Copy Meta: regenerar ${feed_plan.regenerate_primary_slots.length} primary, ${feed_plan.regenerate_headline_slots.length} headline${feed_plan.regenerate_description ? ', description' : ''}`
|
||||
);
|
||||
} else summary.push('Conservar copy Meta');
|
||||
|
||||
if (
|
||||
!regenerate.image &&
|
||||
!regenerate.hook &&
|
||||
!regenerate.support &&
|
||||
!regenerate.feed &&
|
||||
!hookOverride &&
|
||||
!supportOverride
|
||||
) {
|
||||
throw new Error(
|
||||
'No hay nada que cambiar. Desmarca alguna capa o edita hook/support.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
selection: {
|
||||
layers,
|
||||
hook_override: hookOverride || null,
|
||||
support_override: supportOverride || null,
|
||||
feed_keep,
|
||||
},
|
||||
regenerate,
|
||||
next_hook,
|
||||
next_support,
|
||||
feed_plan,
|
||||
canva_only: canva_only_strict,
|
||||
needs_generation_request,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPartialRegenBriefNotes(plan: CreativeRegenPlan): string {
|
||||
return [
|
||||
'REGENERACIÓN PERSONALIZADA POR CAPAS',
|
||||
plan.summary.map((s) => `- ${s}`).join('\n'),
|
||||
'',
|
||||
plan.regenerate.image
|
||||
? 'IMAGEN: generar nueva (Freepik). Luego Canva con hook/support indicados.'
|
||||
: 'IMAGEN: reutilizar raw_image_url / asset actual. No llamar Freepik.',
|
||||
plan.regenerate.hook && !plan.selection.hook_override
|
||||
? 'HOOK: inventar nuevo (sin emojis, máx ~8 palabras).'
|
||||
: `HOOK locked/manual: "${plan.next_hook || ''}"`,
|
||||
plan.regenerate.support && !plan.selection.support_override
|
||||
? 'SUPPORT: inventar nuevo (sin emojis).'
|
||||
: `SUPPORT locked/manual: "${plan.next_support || ''}"`,
|
||||
'',
|
||||
plan.regenerate.feed
|
||||
? [
|
||||
'COPY META (acompaña) — regeneración parcial:',
|
||||
`Primary locked: ${plan.feed_plan.locked_primaries.map((p) => `[${p.index}]`).join(', ') || 'ninguno'}`,
|
||||
`Primary regen slots: ${plan.feed_plan.regenerate_primary_slots.join(', ') || 'ninguno'}`,
|
||||
`Headline locked: ${plan.feed_plan.locked_headlines.map((p) => `[${p.index}]`).join(', ') || 'ninguno'}`,
|
||||
`Headline regen slots: ${plan.feed_plan.regenerate_headline_slots.join(', ') || 'ninguno'}`,
|
||||
plan.feed_plan.regenerate_description
|
||||
? 'Description: REGENERAR (+ emojis)'
|
||||
: 'Description: LOCKED',
|
||||
'Emojis obligatorios en copy Meta. Sin emojis en hook/support.',
|
||||
].join('\n')
|
||||
: 'COPY META: conservar primary_texts / headlines / description tal cual.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -23,12 +23,14 @@ export type CreativeFormat =
|
||||
|
||||
export type CreativeStatus =
|
||||
| 'borrador'
|
||||
| 'revision_agencia'
|
||||
| 'pendiente_aprobacion'
|
||||
| 'aprobada'
|
||||
| 'rechazada'
|
||||
| 'publicada'
|
||||
| 'fatigada'
|
||||
| 'retirada'
|
||||
/** @deprecated legacy — normalizar con normalizeApprovalStatus */
|
||||
| 'draft'
|
||||
| 'in_review'
|
||||
| 'approved'
|
||||
|
||||
@@ -4,6 +4,9 @@ export type CreativeWebhookEvent =
|
||||
| 'creative.approved'
|
||||
| 'creative.rejected'
|
||||
| 'creative.sent_to_client'
|
||||
| 'pipeline.sent_to_client'
|
||||
| 'pipeline.approved'
|
||||
| 'pipeline.rejected'
|
||||
| 'generation_request.created'
|
||||
| 'creative.created';
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { dispatchN8nWebhook, dispatchN8nWebhookAsync } from './creatives-webhooks';
|
||||
import { normalizeRole } from './creatives-visibility';
|
||||
import {
|
||||
buildCopyV2BriefNotes,
|
||||
feedCopyRegenPlan,
|
||||
normalizeKeepSelection,
|
||||
readMetaAdCopy,
|
||||
readOnImageCopy,
|
||||
type FeedCopyKeepSelection,
|
||||
} from './meta-ad-copy';
|
||||
import {
|
||||
buildCreativeRegenPlan,
|
||||
buildPartialRegenBriefNotes,
|
||||
type CreativeRegenSelection,
|
||||
} from './creatives-regen';
|
||||
|
||||
export interface ActionActor {
|
||||
role: 'agency' | 'client' | string;
|
||||
@@ -13,10 +26,67 @@ export function getAdminClient() {
|
||||
return createClient(supabaseUrl, serviceRoleKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marca revisión agencia OK (antes de enviar al cliente).
|
||||
*/
|
||||
export async function markCreativeAgencyReview(
|
||||
creativeId: string,
|
||||
tenantId: string,
|
||||
actor: ActionActor
|
||||
) {
|
||||
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 prev = String(creative.status || '');
|
||||
if (prev !== 'borrador' && prev !== 'draft' && prev !== 'revision_agencia') {
|
||||
throw new Error('Solo se puede marcar revisión agencia desde borrador');
|
||||
}
|
||||
|
||||
const prevStatus = creative.status;
|
||||
const { data: updated, error: updateErr } = await adminClient
|
||||
.from('creatives')
|
||||
.update({
|
||||
status: 'revision_agencia',
|
||||
visibility: 'agency',
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (updateErr) {
|
||||
throw new Error(updateErr.message);
|
||||
}
|
||||
|
||||
await adminClient.from('creative_events').insert({
|
||||
tenant_id: tenantId,
|
||||
creative_id: creativeId,
|
||||
event_type: 'status_change',
|
||||
actor: actor.userId ? `user:${actor.userId}` : 'agency',
|
||||
from_status: prevStatus,
|
||||
to_status: 'revision_agencia',
|
||||
payload: { action: 'agency_review' },
|
||||
visibility: 'agency',
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Enviar al cliente
|
||||
* Cambia visibility a 'client' y status a 'pendiente_aprobacion'
|
||||
* Dispara webhook 'creative.sent_to_client'
|
||||
* Solo desde borrador o revision_agencia (tras validación interna).
|
||||
*/
|
||||
export async function sendCreativeToClient(creativeId: string, tenantId: string, actor: ActionActor) {
|
||||
const adminClient = getAdminClient();
|
||||
@@ -36,6 +106,17 @@ export async function sendCreativeToClient(creativeId: string, tenantId: string,
|
||||
throw new Error('Solo se puede enviar al cliente si cuenta con imagen final compuesta (asset_final=true)');
|
||||
}
|
||||
|
||||
const prevNorm = String(creative.status || '');
|
||||
if (
|
||||
prevNorm !== 'borrador' &&
|
||||
prevNorm !== 'draft' &&
|
||||
prevNorm !== 'revision_agencia'
|
||||
) {
|
||||
throw new Error(
|
||||
'Solo se puede enviar al cliente desde borrador o revisión agencia'
|
||||
);
|
||||
}
|
||||
|
||||
const prevStatus = creative.status;
|
||||
|
||||
const { data: updated, error: updateErr } = await adminClient
|
||||
@@ -364,6 +445,275 @@ export async function retireCreative(
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pedir v2 solo del copy que ACOMPAÑA (Meta feed).
|
||||
* Atajo de requestCreativePartialV2.
|
||||
*/
|
||||
export async function requestFeedCopyV2(
|
||||
creativeId: string,
|
||||
tenantId: string,
|
||||
actor: ActionActor,
|
||||
keepRaw: Partial<FeedCopyKeepSelection>,
|
||||
extraNotes?: string | null
|
||||
) {
|
||||
return requestCreativePartialV2(
|
||||
creativeId,
|
||||
tenantId,
|
||||
actor,
|
||||
{
|
||||
layers: { image: true, hook: true, support: true },
|
||||
feed_keep: normalizeKeepSelection(keepRaw),
|
||||
},
|
||||
extraNotes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regeneración personalizada: imagen / hook / support / copy Meta (cualquier mix).
|
||||
* - Solo texto en imagen con override manual → Canva WF5
|
||||
* - Resto → generation_request con plan de capas
|
||||
*/
|
||||
export async function requestCreativePartialV2(
|
||||
creativeId: string,
|
||||
tenantId: string,
|
||||
actor: ActionActor,
|
||||
selection: CreativeRegenSelection,
|
||||
extraNotes?: string | null
|
||||
) {
|
||||
const adminClient = getAdminClient();
|
||||
const normRole = normalizeRole(actor.role);
|
||||
|
||||
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 onImage = readOnImageCopy(creative);
|
||||
const feed = readMetaAdCopy(creative);
|
||||
const plan = buildCreativeRegenPlan(
|
||||
{
|
||||
hook: onImage.hook || creative.hook,
|
||||
support: onImage.support,
|
||||
feed,
|
||||
},
|
||||
selection
|
||||
);
|
||||
|
||||
// Camino rápido: solo re-montar Canva con textos conocidos
|
||||
if (plan.canva_only) {
|
||||
const recomposed = await recomposeCreativeInCanva(
|
||||
creativeId,
|
||||
tenantId,
|
||||
actor,
|
||||
{
|
||||
hook: plan.next_hook,
|
||||
support: plan.next_support,
|
||||
}
|
||||
);
|
||||
await adminClient.from('creative_events').insert({
|
||||
tenant_id: tenantId,
|
||||
creative_id: creativeId,
|
||||
event_type: 'partial_regen_canva',
|
||||
actor: actor.userId ? `user:${actor.userId}` : normRole,
|
||||
from_status: creative.status,
|
||||
to_status: creative.status,
|
||||
payload: { summary: plan.summary, plan: plan.regenerate },
|
||||
visibility: 'agency',
|
||||
});
|
||||
return {
|
||||
creative: recomposed,
|
||||
generation_request: null,
|
||||
plan,
|
||||
mode: 'canva_only' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const existingMeta =
|
||||
creative.meta && typeof creative.meta === 'object'
|
||||
? { ...(creative.meta as Record<string, unknown>) }
|
||||
: {};
|
||||
const prevCopy =
|
||||
existingMeta.copy && typeof existingMeta.copy === 'object'
|
||||
? { ...(existingMeta.copy as Record<string, unknown>) }
|
||||
: {};
|
||||
|
||||
const nextMeta = {
|
||||
...existingMeta,
|
||||
support: plan.next_support ?? existingMeta.support,
|
||||
copy: {
|
||||
...prevCopy,
|
||||
primary_texts: feed.primary_texts.filter(Boolean),
|
||||
headlines: feed.headlines.filter(Boolean),
|
||||
description: feed.description,
|
||||
hook: plan.next_hook ?? onImage.hook,
|
||||
support: plan.next_support ?? onImage.support,
|
||||
partial_v2: {
|
||||
requested_at: new Date().toISOString(),
|
||||
regenerate: plan.regenerate,
|
||||
feed: plan.feed_plan,
|
||||
next_hook: plan.next_hook,
|
||||
next_support: plan.next_support,
|
||||
summary: plan.summary,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const patch: Record<string, unknown> = {
|
||||
meta: nextMeta,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
if (plan.next_hook != null) patch.hook = plan.next_hook;
|
||||
if (!plan.regenerate.feed) {
|
||||
patch.primary_text = creative.primary_text;
|
||||
patch.headline = creative.headline;
|
||||
patch.description = creative.description;
|
||||
} else {
|
||||
patch.primary_text =
|
||||
plan.feed_plan.locked_primaries.find((p) => p.index === 0)?.text ||
|
||||
creative.primary_text;
|
||||
patch.headline =
|
||||
plan.feed_plan.locked_headlines.find((p) => p.index === 0)?.text ||
|
||||
creative.headline;
|
||||
if (plan.feed_plan.keep.description) {
|
||||
patch.description = feed.description || creative.description;
|
||||
}
|
||||
}
|
||||
|
||||
const { data: updated, error: updateErr } = await adminClient
|
||||
.from('creatives')
|
||||
.update(patch)
|
||||
.eq('id', creativeId)
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (updateErr) {
|
||||
throw new Error(updateErr.message);
|
||||
}
|
||||
|
||||
if (!plan.needs_generation_request) {
|
||||
return {
|
||||
creative: updated,
|
||||
generation_request: null,
|
||||
plan,
|
||||
mode: 'meta_only' as const,
|
||||
};
|
||||
}
|
||||
|
||||
const { data: tenant } = await adminClient
|
||||
.from('tenants')
|
||||
.select('slug, campaign_audience, campaign_products, default_landing_url')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
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 rawImageUrl =
|
||||
(existingMeta as { raw_image_url?: string }).raw_image_url ||
|
||||
creative.asset_url;
|
||||
|
||||
const notes = [
|
||||
buildPartialRegenBriefNotes(plan),
|
||||
plan.regenerate.feed ? buildCopyV2BriefNotes(plan.feed_plan) : '',
|
||||
extraNotes?.trim() ? `Notas agencia: ${extraNotes.trim()}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
const briefPayload = {
|
||||
client_id: tenantId,
|
||||
tenant_id: tenantId,
|
||||
client_slug: tenant?.slug || 'bps',
|
||||
reason: 'manual',
|
||||
source_creative_id: creative.id,
|
||||
format: creative.format || 'estatico',
|
||||
aspect_ratio: '4:5',
|
||||
variants_requested: 1,
|
||||
avoid_angles: plan.regenerate.image && creative.angle ? [creative.angle] : [],
|
||||
campaign_name: campaignName,
|
||||
adset_id: creative.adset_id || null,
|
||||
audience,
|
||||
product,
|
||||
landing_url: landingUrl,
|
||||
language: 'es',
|
||||
copy_only: !plan.regenerate.image && plan.regenerate.feed,
|
||||
keep_asset: !plan.regenerate.image,
|
||||
keep_on_image: !plan.regenerate.hook && !plan.regenerate.support,
|
||||
raw_image_url: !plan.regenerate.image ? rawImageUrl : null,
|
||||
locked_hook: plan.regenerate.hook ? null : plan.next_hook,
|
||||
locked_support: plan.regenerate.support ? null : plan.next_support,
|
||||
partial_regen: plan,
|
||||
copy_v2: plan.regenerate.feed ? plan.feed_plan : null,
|
||||
notes,
|
||||
};
|
||||
|
||||
const { data: genReq, error: genErr } = await adminClient
|
||||
.from('generation_requests')
|
||||
.insert({
|
||||
tenant_id: tenantId,
|
||||
reason: 'manual',
|
||||
source_creative_id: creative.id,
|
||||
brief: briefPayload,
|
||||
status: 'pendiente',
|
||||
})
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (genErr) {
|
||||
throw new Error(genErr.message);
|
||||
}
|
||||
|
||||
await adminClient.from('creative_events').insert({
|
||||
tenant_id: tenantId,
|
||||
creative_id: creativeId,
|
||||
event_type: 'partial_regen_requested',
|
||||
actor: actor.userId ? `user:${actor.userId}` : normRole,
|
||||
from_status: creative.status,
|
||||
to_status: creative.status,
|
||||
payload: {
|
||||
summary: plan.summary,
|
||||
regenerate: plan.regenerate,
|
||||
generation_request_id: genReq?.id,
|
||||
},
|
||||
visibility: 'agency',
|
||||
});
|
||||
|
||||
if (genReq) {
|
||||
dispatchN8nWebhookAsync('generation_request.created', {
|
||||
generation_request: {
|
||||
...genReq,
|
||||
client_id: genReq.tenant_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
creative: updated,
|
||||
generation_request: genReq,
|
||||
plan,
|
||||
mode: 'generation_request' as const,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
350
src/lib/meta-ad-copy.ts
Normal file
350
src/lib/meta-ad-copy.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Dos capas de texto en una creatividad Ads Meta:
|
||||
*
|
||||
* 1) DENTRO de la pieza (Canva / imagen) — sin límites 120/30; sin emojis
|
||||
* - hook + support
|
||||
*
|
||||
* 2) QUE ACOMPAÑA la pieza (Meta feed) — SÍ límites + SIEMPRE emojis
|
||||
* - 5 primary_text ≤ 120
|
||||
* - 5 headline ≤ 30
|
||||
* - 1 description (info + bullets + CTA + emojis)
|
||||
*
|
||||
* Selección parcial (v2): conservar variantes que gustan y regenerar solo el resto.
|
||||
*/
|
||||
|
||||
export const META_PRIMARY_MAX = 120;
|
||||
export const META_HEADLINE_MAX = 30;
|
||||
export const META_PRIMARY_COUNT = 5;
|
||||
export const META_HEADLINE_COUNT = 5;
|
||||
|
||||
/** Texto montado en la creatividad (imagen). */
|
||||
export type OnImageCopy = {
|
||||
hook: string;
|
||||
support: string;
|
||||
};
|
||||
|
||||
/** Copy del anuncio en Meta (junto a la creatividad). */
|
||||
export type MetaFeedCopy = {
|
||||
primary_texts: string[];
|
||||
headlines: string[];
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type MetaAdCopyBundle = OnImageCopy & MetaFeedCopy;
|
||||
|
||||
/** @deprecated prefer MetaFeedCopy */
|
||||
export type MetaAdCopy = MetaFeedCopy;
|
||||
|
||||
export type FeedCopyKeepSelection = {
|
||||
/** Índices 0–4 de primary_texts a conservar */
|
||||
primary_indices: number[];
|
||||
/** Índices 0–4 de headlines a conservar */
|
||||
headline_indices: number[];
|
||||
/** true = conservar description actual */
|
||||
description: boolean;
|
||||
};
|
||||
|
||||
export function clip(s: string, max: number): string {
|
||||
const t = s.trim();
|
||||
if (t.length <= max) return t;
|
||||
return t.slice(0, max).trim();
|
||||
}
|
||||
|
||||
function asStringList(v: unknown, count: number, maxLen: number): string[] {
|
||||
const raw = Array.isArray(v)
|
||||
? v
|
||||
: typeof v === 'string' && v.trim()
|
||||
? [v]
|
||||
: [];
|
||||
const out: string[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'string') continue;
|
||||
const c = clip(item, maxLen);
|
||||
if (c) out.push(c);
|
||||
if (out.length >= count) break;
|
||||
}
|
||||
while (out.length < count) out.push('');
|
||||
return out.slice(0, count);
|
||||
}
|
||||
|
||||
function uniqIndices(raw: unknown, maxExclusive: number): number[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const set = new Set<number>();
|
||||
for (const x of raw) {
|
||||
const n = Number(x);
|
||||
if (Number.isInteger(n) && n >= 0 && n < maxExclusive) set.add(n);
|
||||
}
|
||||
return [...set].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function readOnImageCopy(row: {
|
||||
hook?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
}): OnImageCopy {
|
||||
const meta = (row.meta || {}) as Record<string, unknown>;
|
||||
const support =
|
||||
typeof meta.support === 'string'
|
||||
? meta.support
|
||||
: typeof (meta.copy as { support?: string } | undefined)?.support ===
|
||||
'string'
|
||||
? String((meta.copy as { support?: string }).support)
|
||||
: '';
|
||||
return {
|
||||
hook: (row.hook || '').trim(),
|
||||
support: support.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function readMetaAdCopy(row: {
|
||||
primary_text?: string | null;
|
||||
headline?: string | null;
|
||||
description?: string | null;
|
||||
hook?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
}): MetaFeedCopy {
|
||||
const meta = (row.meta || {}) as Record<string, unknown>;
|
||||
const nested =
|
||||
meta.copy && typeof meta.copy === 'object'
|
||||
? (meta.copy as Record<string, unknown>)
|
||||
: meta;
|
||||
|
||||
const primary_texts = asStringList(
|
||||
nested.primary_texts ?? meta.primary_texts ?? row.primary_text,
|
||||
META_PRIMARY_COUNT,
|
||||
META_PRIMARY_MAX
|
||||
);
|
||||
const headlines = asStringList(
|
||||
nested.headlines ?? meta.headlines ?? row.headline,
|
||||
META_HEADLINE_COUNT,
|
||||
META_HEADLINE_MAX
|
||||
);
|
||||
const description = clip(
|
||||
String(
|
||||
nested.description ?? meta.description_long ?? row.description ?? ''
|
||||
),
|
||||
5000
|
||||
);
|
||||
|
||||
if (!primary_texts[0] && row.primary_text) {
|
||||
primary_texts[0] = clip(row.primary_text, META_PRIMARY_MAX);
|
||||
}
|
||||
if (!headlines[0] && row.headline) {
|
||||
headlines[0] = clip(row.headline, META_HEADLINE_MAX);
|
||||
}
|
||||
|
||||
return { primary_texts, headlines, description };
|
||||
}
|
||||
|
||||
export function readMetaAdCopyBundle(row: {
|
||||
primary_text?: string | null;
|
||||
headline?: string | null;
|
||||
description?: string | null;
|
||||
hook?: string | null;
|
||||
meta?: Record<string, unknown> | null;
|
||||
}): MetaAdCopyBundle {
|
||||
return { ...readOnImageCopy(row), ...readMetaAdCopy(row) };
|
||||
}
|
||||
|
||||
export function normalizeKeepSelection(
|
||||
raw: Partial<FeedCopyKeepSelection> | null | undefined
|
||||
): FeedCopyKeepSelection {
|
||||
return {
|
||||
primary_indices: uniqIndices(raw?.primary_indices, META_PRIMARY_COUNT),
|
||||
headline_indices: uniqIndices(raw?.headline_indices, META_HEADLINE_COUNT),
|
||||
description: Boolean(raw?.description),
|
||||
};
|
||||
}
|
||||
|
||||
/** Qué hay que regenerar a partir de lo que se conserva. */
|
||||
export function feedCopyRegenPlan(
|
||||
feed: MetaFeedCopy,
|
||||
keep: FeedCopyKeepSelection
|
||||
): {
|
||||
keep: FeedCopyKeepSelection;
|
||||
locked_primaries: { index: number; text: string }[];
|
||||
locked_headlines: { index: number; text: string }[];
|
||||
locked_description: string | null;
|
||||
regenerate_primary_slots: number[];
|
||||
regenerate_headline_slots: number[];
|
||||
regenerate_description: boolean;
|
||||
needsRegen: boolean;
|
||||
} {
|
||||
const k = normalizeKeepSelection(keep);
|
||||
const locked_primaries = k.primary_indices
|
||||
.map((index) => ({ index, text: feed.primary_texts[index] || '' }))
|
||||
.filter((x) => x.text);
|
||||
const locked_headlines = k.headline_indices
|
||||
.map((index) => ({ index, text: feed.headlines[index] || '' }))
|
||||
.filter((x) => x.text);
|
||||
|
||||
const regenerate_primary_slots = Array.from(
|
||||
{ length: META_PRIMARY_COUNT },
|
||||
(_, i) => i
|
||||
).filter((i) => !k.primary_indices.includes(i));
|
||||
|
||||
const regenerate_headline_slots = Array.from(
|
||||
{ length: META_HEADLINE_COUNT },
|
||||
(_, i) => i
|
||||
).filter((i) => !k.headline_indices.includes(i));
|
||||
|
||||
const regenerate_description = !k.description;
|
||||
const locked_description = k.description ? feed.description || null : null;
|
||||
|
||||
const needsRegen =
|
||||
regenerate_primary_slots.length > 0 ||
|
||||
regenerate_headline_slots.length > 0 ||
|
||||
regenerate_description;
|
||||
|
||||
return {
|
||||
keep: k,
|
||||
locked_primaries,
|
||||
locked_headlines,
|
||||
locked_description,
|
||||
regenerate_primary_slots,
|
||||
regenerate_headline_slots,
|
||||
regenerate_description,
|
||||
needsRegen,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMetaAdCopyInput(input: {
|
||||
primary_text?: unknown;
|
||||
primary_texts?: unknown;
|
||||
headline?: unknown;
|
||||
headlines?: unknown;
|
||||
description?: unknown;
|
||||
hook?: unknown;
|
||||
support?: unknown;
|
||||
cta?: unknown;
|
||||
meta?: Record<string, unknown> | null;
|
||||
}): {
|
||||
primary_text: string | null;
|
||||
headline: string | null;
|
||||
description: string | null;
|
||||
hook: string | null;
|
||||
meta: Record<string, unknown>;
|
||||
onImage: OnImageCopy;
|
||||
feed: MetaFeedCopy;
|
||||
} {
|
||||
const primary_texts = asStringList(
|
||||
input.primary_texts ?? input.primary_text,
|
||||
META_PRIMARY_COUNT,
|
||||
META_PRIMARY_MAX
|
||||
);
|
||||
const headlines = asStringList(
|
||||
input.headlines ?? input.headline,
|
||||
META_HEADLINE_COUNT,
|
||||
META_HEADLINE_MAX
|
||||
);
|
||||
|
||||
let description = '';
|
||||
if (typeof input.description === 'string') {
|
||||
description = input.description.trim();
|
||||
}
|
||||
const cta = typeof input.cta === 'string' ? input.cta.trim() : '';
|
||||
if (description && cta && !description.includes(cta)) {
|
||||
description = `${description}\n\n→ ${cta}`;
|
||||
}
|
||||
|
||||
const hook = typeof input.hook === 'string' ? input.hook.trim() : '';
|
||||
const support =
|
||||
typeof input.support === 'string'
|
||||
? input.support.trim()
|
||||
: typeof input.meta?.support === 'string'
|
||||
? String(input.meta.support).trim()
|
||||
: '';
|
||||
|
||||
const onImage: OnImageCopy = { hook, support };
|
||||
const feed: MetaFeedCopy = {
|
||||
primary_texts,
|
||||
headlines,
|
||||
description,
|
||||
};
|
||||
|
||||
const meta = { ...(input.meta || {}) };
|
||||
if (support) meta.support = support;
|
||||
meta.copy = {
|
||||
hook: hook || undefined,
|
||||
support: support || undefined,
|
||||
primary_texts: primary_texts.filter(Boolean),
|
||||
headlines: headlines.filter(Boolean),
|
||||
description,
|
||||
limits: {
|
||||
applies_to: 'feed_accompanying_only',
|
||||
primary_max: META_PRIMARY_MAX,
|
||||
headline_max: META_HEADLINE_MAX,
|
||||
primary_count: META_PRIMARY_COUNT,
|
||||
headline_count: META_HEADLINE_COUNT,
|
||||
emojis: 'required_on_feed_copy',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
primary_text: primary_texts[0] || null,
|
||||
headline: headlines[0] || null,
|
||||
description: description || null,
|
||||
hook: hook || null,
|
||||
meta,
|
||||
onImage,
|
||||
feed,
|
||||
};
|
||||
}
|
||||
|
||||
export function metaAdCopySkillRules(): string {
|
||||
return [
|
||||
'## Dos capas de texto (no las mezcles)',
|
||||
'',
|
||||
'### 1) Texto DENTRO de la creatividad (imagen / Canva)',
|
||||
'- `hook` + `support` (máx. 2 niveles). Sin límites de 120/30 caracteres.',
|
||||
'- **Sin emojis** en la imagen.',
|
||||
'- Va a plantilla Canva. NO es el primary_text ni el headline de Meta.',
|
||||
'',
|
||||
'### 2) Texto que ACOMPAÑA la creatividad (anuncio Meta)',
|
||||
`- **5 primary_text** ≤ ${META_PRIMARY_MAX} caracteres c/u. Variantes distintas.`,
|
||||
`- **5 headline** ≤ ${META_HEADLINE_MAX} caracteres c/u.`,
|
||||
'- **1 description**: info completa + bullet points + CTA.',
|
||||
'- **Emojis OBLIGATORIOS** en primary_text, headline y description (al menos 1 emoji relevante por variante; en description varios repartidos en bullets/cierre).',
|
||||
'- Sin enlaces ni hashtags en primary/headline.',
|
||||
'',
|
||||
'### Regeneración parcial (v2)',
|
||||
'Si el brief trae `copy_v2` con variantes `locked_*`, **copia esas literales** en los índices indicados y solo inventa nuevas para los slots `regenerate_*`. No cambies hook/support/imagen salvo que el brief lo pida.',
|
||||
'',
|
||||
'Devuelve en JSON:',
|
||||
'```',
|
||||
'{',
|
||||
' "hook": "...",',
|
||||
' "support": "...",',
|
||||
' "primary_texts": ["...😀","...✨","...💪","...🎯","...🔥"],',
|
||||
' "headlines": ["...✅","...🚀","...💡","...⭐","...🙌"],',
|
||||
' "description": "...\\n• ...✨\\n• ...\\n→ CTA 👉",',
|
||||
' "cta": "..."',
|
||||
'}',
|
||||
'```',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Notas para el brief de generation_request cuando hay v2 parcial. */
|
||||
export function buildCopyV2BriefNotes(plan: ReturnType<typeof feedCopyRegenPlan>): string {
|
||||
const lines = [
|
||||
'REGENERACIÓN PARCIAL DE COPY QUE ACOMPAÑA (capa Meta feed). No tocar texto en imagen (hook/support) ni asset.',
|
||||
'Conservar EXACTAMENTE las variantes locked; regenerar solo los slots indicados.',
|
||||
'Límites solo en copy que acompaña: primary ≤120, headline ≤30. Emojis OBLIGATORIOS en cada variante nueva y en description si se regenera.',
|
||||
'',
|
||||
`Primary locked: ${
|
||||
plan.locked_primaries.length
|
||||
? plan.locked_primaries.map((p) => `[${p.index}] "${p.text}"`).join(' · ')
|
||||
: '(ninguno)'
|
||||
}`,
|
||||
`Primary a regenerar (índices): ${plan.regenerate_primary_slots.join(', ') || '(ninguno)'}`,
|
||||
`Headline locked: ${
|
||||
plan.locked_headlines.length
|
||||
? plan.locked_headlines.map((p) => `[${p.index}] "${p.text}"`).join(' · ')
|
||||
: '(ninguno)'
|
||||
}`,
|
||||
`Headline a regenerar (índices): ${plan.regenerate_headline_slots.join(', ') || '(ninguno)'}`,
|
||||
plan.regenerate_description
|
||||
? 'Description: REGENERAR completa (info + bullets + CTA + emojis).'
|
||||
: `Description LOCKED: "${plan.locked_description || ''}"`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -45,12 +45,12 @@ export function pipelinePublishDay(it: PipelineCalendarRow): string | null {
|
||||
}
|
||||
const st = (it.status || '').toLowerCase();
|
||||
const ref = (it.external_ref || '').toLowerCase();
|
||||
if (st === 'publicado' || ref.startsWith('ig-')) {
|
||||
if (st === 'publicada' || st === 'publicado' || ref.startsWith('ig-')) {
|
||||
const d = (it.created_at || '').slice(0, 10);
|
||||
return d || null;
|
||||
}
|
||||
// Approved + scheduled pieces also show on the calendar day (live plan).
|
||||
if (st === 'aprobado' && it.scheduled_at) {
|
||||
if ((st === 'aprobada' || st === 'aprobado') && it.scheduled_at) {
|
||||
return it.scheduled_at.slice(0, 10);
|
||||
}
|
||||
return null;
|
||||
@@ -133,7 +133,11 @@ export function mergePipelineIntoPlans(
|
||||
if (!day) continue;
|
||||
// Show published + approved-with-schedule on the live calendar.
|
||||
const st = (it.status || '').toLowerCase();
|
||||
if (st !== 'publicado' && !(st === 'aprobado' && it.scheduled_at)) {
|
||||
if (
|
||||
st !== 'publicada' &&
|
||||
st !== 'publicado' &&
|
||||
!((st === 'aprobada' || st === 'aprobado') && it.scheduled_at)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const ig = instagramUrlFromNotes(it.notes);
|
||||
|
||||
@@ -68,7 +68,7 @@ export type DuePublishResult = {
|
||||
* Fetch and publish due items.
|
||||
* Criteria: scheduled_at <= now, cover present, and
|
||||
* - publish_status in (queued, failed), OR
|
||||
* - publish_status=idle AND status=aprobado
|
||||
* - publish_status=idle AND status=aprobada
|
||||
*/
|
||||
export async function processDuePipelinePublishes(opts?: {
|
||||
limit?: number;
|
||||
@@ -107,7 +107,7 @@ export async function processDuePipelinePublishes(opts?: {
|
||||
const q1 =
|
||||
`${base}/rest/v1/creative_pipeline?publish_status=in.(queued,failed)&${common}`;
|
||||
const q2 =
|
||||
`${base}/rest/v1/creative_pipeline?publish_status=eq.idle&status=eq.aprobado&${common}`;
|
||||
`${base}/rest/v1/creative_pipeline?publish_status=eq.idle&status=eq.aprobada&${common}`;
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
fetch(q1, { headers, cache: 'no-store' }),
|
||||
@@ -165,7 +165,7 @@ export async function processDuePipelinePublishes(opts?: {
|
||||
publish_status: 'publishing',
|
||||
publish_attempts: attempts,
|
||||
publish_error: null,
|
||||
status: 'aprobado',
|
||||
status: 'aprobada',
|
||||
});
|
||||
if (!lock.ok) {
|
||||
results.push({
|
||||
@@ -263,7 +263,7 @@ export async function processDuePipelinePublishes(opts?: {
|
||||
publish_status: 'published',
|
||||
publish_error: null,
|
||||
published_at: new Date().toISOString(),
|
||||
status: 'publicado',
|
||||
status: 'publicada',
|
||||
meta_ig_media_id: igMediaId,
|
||||
meta_fb_post_id: fbPostId,
|
||||
caption: cleanCaption || null,
|
||||
|
||||
Reference in New Issue
Block a user