feat(skills): update BPS & static skills, add audience to brief, inline angle/hook editing and montage notes
This commit is contained in:
@@ -84,10 +84,20 @@ export async function POST(req: NextRequest) {
|
||||
// Get tenant slug
|
||||
const { data: tenant } = await admin
|
||||
.from('tenants')
|
||||
.select('slug, name')
|
||||
.select('slug, name, campaign_audience')
|
||||
.eq('id', tenantId)
|
||||
.maybeSingle();
|
||||
|
||||
const campaignName = body.campaign_name || null;
|
||||
const campaignAudienceMap = (tenant as any)?.campaign_audience || {};
|
||||
let audience: 'b2b' | 'b2c' | null = body.audience || null;
|
||||
if (!audience && campaignName && campaignAudienceMap && typeof campaignAudienceMap === 'object' && campaignAudienceMap[campaignName]) {
|
||||
const val = String(campaignAudienceMap[campaignName]).toLowerCase().trim();
|
||||
if (val === 'b2b' || val === 'b2c') {
|
||||
audience = val;
|
||||
}
|
||||
}
|
||||
|
||||
const brief = {
|
||||
tenant_id: tenantId,
|
||||
client_id: tenantId,
|
||||
@@ -96,8 +106,9 @@ export async function POST(req: NextRequest) {
|
||||
source_creative_id: body.source_creative_id || null,
|
||||
format: body.format || 'estatico',
|
||||
variants_requested: Number(body.variants_requested || 3),
|
||||
campaign_name: body.campaign_name || null,
|
||||
campaign_name: campaignName,
|
||||
adset_id: body.adset_id || null,
|
||||
audience,
|
||||
notes: body.notes || null,
|
||||
};
|
||||
|
||||
|
||||
@@ -48,26 +48,32 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
const toInsert = items.map((c: any) => ({
|
||||
tenant_id: tenantId,
|
||||
campaign_name: c.campaign_name || null,
|
||||
adset_id: c.adset_id || null,
|
||||
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,
|
||||
cta: c.cta || null,
|
||||
asset_url: c.asset_url || null,
|
||||
thumbnail_url: c.thumbnail_url || null,
|
||||
status: 'borrador', // nacen siempre en borrador
|
||||
visibility: 'agency', // nacen siempre en agency
|
||||
created_by: 'claude', // creadas por Claude
|
||||
parent_id: c.parent_id || defaultParentId,
|
||||
generation_request_id: c.generation_request_id || genRequestId || null,
|
||||
meta: c.meta || {},
|
||||
}));
|
||||
const toInsert = items.map((c: any) => {
|
||||
const meta = { ...(c.meta || {}) };
|
||||
if (c.composition_notes) {
|
||||
meta.composition_notes = c.composition_notes;
|
||||
}
|
||||
return {
|
||||
tenant_id: tenantId,
|
||||
campaign_name: c.campaign_name || null,
|
||||
adset_id: c.adset_id || null,
|
||||
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,
|
||||
cta: c.cta || null,
|
||||
asset_url: c.asset_url || null,
|
||||
thumbnail_url: c.thumbnail_url || null,
|
||||
status: 'borrador', // nacen siempre en borrador
|
||||
visibility: 'agency', // nacen siempre en agency
|
||||
created_by: 'claude', // creadas por Claude
|
||||
parent_id: c.parent_id || defaultParentId,
|
||||
generation_request_id: c.generation_request_id || genRequestId || null,
|
||||
meta,
|
||||
};
|
||||
});
|
||||
|
||||
const { data: inserted, error } = await adminClient
|
||||
.from('creatives')
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function POST(req: NextRequest) {
|
||||
// 1. Obtener la creatividad a marcar
|
||||
const { data: creative, error: getErr } = await adminClient
|
||||
.from('creatives')
|
||||
.select('*, tenant:tenants(slug, name)')
|
||||
.select('*, tenant:tenants(slug, name, campaign_audience)')
|
||||
.eq('id', creativeId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.maybeSingle();
|
||||
@@ -77,7 +77,17 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (rules?.auto_request_relief) {
|
||||
const avoidAngles = creative.angle ? [creative.angle] : [];
|
||||
const clientSlug = creative.tenant?.slug || 'bps';
|
||||
const clientSlug = (creative.tenant as any)?.slug || 'bps';
|
||||
const campaignAudienceMap = (creative.tenant as any)?.campaign_audience || {};
|
||||
const campaignName = creative.campaign_name || '';
|
||||
|
||||
let audience: 'b2b' | 'b2c' | null = null;
|
||||
if (campaignName && campaignAudienceMap && typeof campaignAudienceMap === 'object' && campaignAudienceMap[campaignName]) {
|
||||
const val = String(campaignAudienceMap[campaignName]).toLowerCase().trim();
|
||||
if (val === 'b2b' || val === 'b2c') {
|
||||
audience = val;
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = new Date();
|
||||
deadline.setDate(deadline.getDate() + 1); // 24 horas después
|
||||
@@ -94,6 +104,7 @@ export async function POST(req: NextRequest) {
|
||||
avoid_angles: avoidAngles,
|
||||
campaign_name: creative.campaign_name || 'General',
|
||||
adset_id: creative.adset_id || null,
|
||||
audience,
|
||||
language: 'es',
|
||||
deadline: deadline.toISOString(),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createClient } from '@supabase/supabase-js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = await authenticateIntegrationRequest(req, { requireTenant: true });
|
||||
if (!auth.ok) {
|
||||
@@ -61,13 +63,20 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
client_skill: clientSkill,
|
||||
format_skill: formatSkill,
|
||||
},
|
||||
// Compatibilidad en primer nivel
|
||||
client_skill: clientSkill,
|
||||
format_skill: formatSkill,
|
||||
},
|
||||
// Compatibilidad en primer nivel
|
||||
client_skill: clientSkill,
|
||||
format_skill: formatSkill,
|
||||
});
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
BarChart2,
|
||||
FileCheck2,
|
||||
Filter,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { PageHeader, Surface, EmptyState, SegmentedControl } from '@/components/ui';
|
||||
|
||||
@@ -49,7 +50,8 @@ export type CreativeItem = {
|
||||
parent_id?: string | null;
|
||||
generation_request_id?: string | null;
|
||||
asset_final: boolean;
|
||||
created_by?: string;
|
||||
title?: string | null;
|
||||
meta?: Record<string, any> | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metrics?: {
|
||||
@@ -132,6 +134,42 @@ export default function AgencyCreativesView({
|
||||
const [detailCreative, setDetailCreative] = useState<CreativeItem | null>(null);
|
||||
const [detailEvents, setDetailEvents] = useState<any[]>([]);
|
||||
|
||||
// Edición inline de ángulo y hook
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineAngle, setInlineAngle] = useState('');
|
||||
const [inlineHook, setInlineHook] = useState('');
|
||||
const [savingInline, setSavingInline] = useState(false);
|
||||
|
||||
const handleSaveInline = async (creativeId: string) => {
|
||||
setSavingInline(true);
|
||||
try {
|
||||
const res = await fetch(`/api/creatives/${creativeId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
angle: inlineAngle.trim() || null,
|
||||
hook: inlineHook.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || 'Error al guardar');
|
||||
}
|
||||
setCreatives((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === creativeId
|
||||
? { ...c, angle: inlineAngle.trim() || null, hook: inlineHook.trim() || null }
|
||||
: c
|
||||
)
|
||||
);
|
||||
setInlineEditingId(null);
|
||||
} catch (err: any) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setSavingInline(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Cargar datos
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -477,31 +515,134 @@ export default function AgencyCreativesView({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Titular y Copy */}
|
||||
<div className="space-y-1 mb-2.5">
|
||||
<h4 className="text-xs font-bold text-gray-900 line-clamp-1">
|
||||
{c.headline || c.hook || 'Sin titular'}
|
||||
</h4>
|
||||
{c.primary_text ? (
|
||||
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
|
||||
{c.primary_text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Info Titular, Hook, Montaje y Copy con edición inline */}
|
||||
{inlineEditingId === c.id ? (
|
||||
<div
|
||||
className="p-2.5 rounded-xl bg-gray-50 border border-brand-300 space-y-2 mb-3 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
|
||||
Ángulo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inlineAngle}
|
||||
onChange={(e) => setInlineAngle(e.target.value)}
|
||||
placeholder="ej: dolor_tiempo, prueba_social, certificados..."
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[10px] font-semibold text-gray-500 block uppercase tracking-wider">
|
||||
Hook / Gancho
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={inlineHook}
|
||||
onChange={(e) => setInlineHook(e.target.value)}
|
||||
placeholder="Gancho de la creatividad o nombre del anuncio..."
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs rounded border border-gray-300 bg-white resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1.5 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingInline}
|
||||
onClick={() => setInlineEditingId(null)}
|
||||
className="px-2 py-1 rounded text-[11px] font-medium text-gray-600 hover:bg-gray-200"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingInline}
|
||||
onClick={() => void handleSaveInline(c.id)}
|
||||
className="px-2.5 py-1 rounded text-[11px] font-semibold text-white bg-brand-500 hover:bg-brand-600"
|
||||
>
|
||||
{savingInline ? 'Guardando…' : 'Guardar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 mb-2.5">
|
||||
{/* Titular */}
|
||||
<h4 className="text-xs font-bold text-gray-900 line-clamp-1">
|
||||
{c.headline || c.title || 'Sin titular'}
|
||||
</h4>
|
||||
|
||||
{/* Meta: Ángulo & Campaña */}
|
||||
<div className="flex flex-wrap items-center gap-1 mb-3 text-[10px] text-gray-500">
|
||||
{c.angle ? (
|
||||
<span className="rounded-md bg-gray-100 px-1.5 py-0.5 font-medium text-gray-700">
|
||||
{c.angle}
|
||||
</span>
|
||||
) : null}
|
||||
{c.campaign_name ? (
|
||||
<span className="rounded-md bg-gray-50 px-1.5 py-0.5 text-gray-600 border border-gray-200 line-clamp-1 max-w-[150px]">
|
||||
{c.campaign_name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Hook con botón de edición rápida */}
|
||||
<div className="flex items-start justify-between gap-1 group/hook">
|
||||
<p className="text-[11px] text-gray-700 leading-snug">
|
||||
<span className="font-semibold text-gray-900">Hook: </span>
|
||||
{c.hook ? (
|
||||
<span>{c.hook}</span>
|
||||
) : (
|
||||
<span className="text-amber-600 italic font-medium">Sin hook definido</span>
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInlineEditingId(c.id);
|
||||
setInlineAngle(c.angle || '');
|
||||
setInlineHook(c.hook || '');
|
||||
}}
|
||||
className="text-gray-400 hover:text-brand-600 shrink-0 p-0.5 rounded transition-colors"
|
||||
title="Editar Hook y Ángulo"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Montaje / composition_notes debajo del hook */}
|
||||
{c.meta?.composition_notes && (
|
||||
<div className="rounded-lg bg-amber-50/90 p-2 border border-amber-200/60 text-[11px] space-y-0.5">
|
||||
<span className="font-semibold text-amber-950 block text-[10px] uppercase tracking-wider">
|
||||
Montaje
|
||||
</span>
|
||||
<p className="text-amber-900 whitespace-pre-line leading-relaxed text-[11px]">
|
||||
{c.meta.composition_notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Copy principal */}
|
||||
{c.primary_text ? (
|
||||
<p className="text-[11px] text-gray-600 line-clamp-2 leading-relaxed">
|
||||
{c.primary_text}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Meta: Ángulo & Campaña */}
|
||||
<div className="flex flex-wrap items-center gap-1 pt-1 text-[10px] text-gray-500">
|
||||
{c.angle ? (
|
||||
<span className="rounded-md bg-purple-50 px-1.5 py-0.5 font-medium text-purple-700 border border-purple-200">
|
||||
{c.angle}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInlineEditingId(c.id);
|
||||
setInlineAngle('');
|
||||
setInlineHook(c.hook || '');
|
||||
}}
|
||||
className="rounded-md bg-amber-50 hover:bg-amber-100 px-1.5 py-0.5 text-amber-700 font-medium border border-amber-200"
|
||||
>
|
||||
+ Definir ángulo
|
||||
</button>
|
||||
)}
|
||||
{c.campaign_name ? (
|
||||
<span className="rounded-md bg-gray-50 px-1.5 py-0.5 text-gray-600 border border-gray-200 line-clamp-1 max-w-[150px]">
|
||||
{c.campaign_name}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Métricas si existen */}
|
||||
{c.metrics ? (
|
||||
@@ -1084,6 +1225,20 @@ export default function AgencyCreativesView({
|
||||
{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}
|
||||
{detailCreative.angle ? (
|
||||
<p className="text-xs text-purple-700 pt-0.5"><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">
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user