feat(calendario): opciones de generación, regenerar y publish IG/FB

Cantidades posts/stories/reels, días, mix mismo día, instrucciones,
regenerar marcadas, texto por slide/guión reel, stories interactivas
y horarios IG/FB separados o iguales.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-15 16:36:45 +02:00
parent 39b1f8ec02
commit 3b57cdce53
10 changed files with 791 additions and 120 deletions

View File

@@ -130,6 +130,16 @@ function normalizePiece(
hook: str(raw.hook, 300),
body: cleanBody,
});
const slide_copy = Array.isArray(raw.slide_copy)
? (raw.slide_copy as { n?: unknown; label?: unknown; text?: unknown }[])
.map((s, i) => ({
n: Number(s.n) || i + 1,
label: s.label != null ? String(s.label).slice(0, 80) : undefined,
text: String(s.text || '').trim().slice(0, 800),
}))
.filter((s) => s.text)
: undefined;
return {
id: str(raw.id, 120) || newPieceId(date, title),
date,
@@ -151,6 +161,9 @@ function normalizePiece(
: /linkedin/i.test(enriched.platform || '')
? '#7C66B8'
: '#2071cf',
slide_copy: slide_copy?.length ? slide_copy : undefined,
reel_script: str(raw.reel_script, 6000) || undefined,
interactive_story: str(raw.interactive_story, 40) || undefined,
};
}

View File

@@ -44,6 +44,8 @@ export async function POST(req: NextRequest) {
const body = (await req.json().catch(() => ({}))) as {
month?: string;
avoid_titles?: string[];
gen_options?: Record<string, unknown>;
options?: Record<string, unknown>;
};
const month = String(body.month || '').slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(month)) {
@@ -55,6 +57,9 @@ export async function POST(req: NextRequest) {
.map((t) => t.trim().slice(0, 160))
.slice(0, 40)
: [];
const genOptions = (body.gen_options || body.options || null) as
| Record<string, unknown>
| null;
let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>(
@@ -132,6 +137,7 @@ export async function POST(req: NextRequest) {
brandbook,
publishedPosts,
avoidTitles,
genOptions: genOptions || undefined,
});
return NextResponse.json({

View File

@@ -302,7 +302,7 @@ export default async function CalendarioPage({
</Link>
</div>
{agency ? (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center justify-end gap-3">
<CalendarCreator
tenantLabel={ctx.label}
existingMonths={plans.map((p) => p.month).filter(Boolean)}
@@ -345,14 +345,14 @@ export default async function CalendarioPage({
return (
<div className="space-y-5">
{agency ? (
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-wrap items-center justify-end gap-3">
{active.month ? (
<DeleteCalendarMonthButton month={active.month} />
) : null}
<CalendarCreator
tenantLabel={ctx.label}
existingMonths={plans.map((p) => p.month).filter(Boolean)}
/>
{active.month ? (
<DeleteCalendarMonthButton month={active.month} />
) : null}
</div>
) : null}
<RrssMonthView

View File

@@ -17,6 +17,12 @@ import {
keepOnlyPieces,
mergeKeepAndRegenerate,
} from '@/lib/calendar-generate';
import {
DEFAULT_GEN_OPTIONS,
WEEKDAY_LABELS,
normalizeGenOptions,
type CalendarGenOptions,
} from '@/lib/calendar-gen-options';
const MONTH_NAMES = [
'Enero',
@@ -126,8 +132,12 @@ export default function CalendarCreator({
const [err, setErr] = useState<string | null>(null);
const [dayZoom, setDayZoom] = useState<string | null>(null);
const [form, setForm] = useState<PieceForm | null>(null);
/** Piezas a conservar (✓) en selección parcial */
const [keepIds, setKeepIds] = useState<Set<string>>(new Set());
/** Piezas marcadas para regenerar (✓) */
const [regenIds, setRegenIds] = useState<Set<string>>(new Set());
const [genOptions, setGenOptions] = useState<CalendarGenOptions>({
...DEFAULT_GEN_OPTIONS,
});
const [showGenPanel, setShowGenPanel] = useState(true);
const monthChoices = useMemo(() => {
const base = ymNow();
@@ -168,7 +178,7 @@ export default function CalendarCreator({
setRationale('');
setSource('');
}
setKeepIds(new Set());
setRegenIds(new Set());
setMonth(ym);
setStep('edit');
} catch (e) {
@@ -178,8 +188,8 @@ export default function CalendarCreator({
}
}
function toggleKeep(id: string) {
setKeepIds((prev) => {
function toggleRegen(id: string) {
setRegenIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
@@ -187,10 +197,10 @@ export default function CalendarCreator({
});
}
function toggleKeepDay(date: string) {
function toggleRegenDay(date: string) {
const ids = (byDate.get(date) || []).map((p) => p.id);
if (!ids.length) return;
setKeepIds((prev) => {
setRegenIds((prev) => {
const next = new Set(prev);
const allOn = ids.every((id) => next.has(id));
if (allOn) ids.forEach((id) => next.delete(id));
@@ -199,10 +209,19 @@ export default function CalendarCreator({
});
}
async function runPreview(mode: 'replace' | 'regen_unkept' = 'replace') {
async function runPreview(mode: 'replace' | 'regen_selected' = 'replace') {
setBusy(true);
setErr(null);
try {
const opts = normalizeGenOptions(genOptions);
const keepIds =
mode === 'regen_selected' && plan?.pieces?.length
? new Set(
(plan.pieces || [])
.filter((p) => !regenIds.has(p.id))
.map((p) => p.id)
)
: new Set<string>();
const keptPieces = (plan?.pieces || []).filter((p) => keepIds.has(p.id));
const r = await fetch('/api/calendar/preview', {
method: 'POST',
@@ -212,29 +231,44 @@ export default function CalendarCreator({
avoid_titles: keptPieces
.map((p) => p.title || p.hook || '')
.filter(Boolean),
gen_options: {
...opts,
regenInstructions:
mode === 'regen_selected'
? opts.regenInstructions || opts.instructions
: opts.instructions,
},
}),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo generar preview');
if (mode === 'regen_unkept' && plan?.pieces?.length && keepIds.size > 0) {
if (keepIds.size >= plan.pieces.length) {
throw new Error(
'Desmarca al menos una pieza (o un día) para regenerar solo las malas.'
);
if (mode === 'regen_selected' && plan?.pieces?.length) {
if (regenIds.size === 0) {
throw new Error('Marca al menos una pieza (o un día) para regenerar.');
}
if (regenIds.size >= plan.pieces.length) {
setPlan(j.plan);
setRationale(j.rationale || '');
setSource(j.source || 'heuristic');
setRegenIds(new Set());
} else {
const merged = mergeKeepAndRegenerate(plan, j.plan, keepIds);
setPlan(merged);
setRationale(
`${j.rationale || ''}
Regeneradas: ${regenIds.size} · Conservadas: ${
keepIds.size
}.`
);
setSource(`${j.source || 'heuristic'}+regen`);
setRegenIds(new Set());
}
const merged = mergeKeepAndRegenerate(plan, j.plan, keepIds);
setPlan(merged);
setRationale(
`${j.rationale || ''}\n\nSelección: ${keepIds.size} conservadas · ${
merged.pieces.length - keepIds.size
} regeneradas.`
);
setSource(`${j.source || 'heuristic'}+parcial`);
} else {
setPlan(j.plan);
setRationale(j.rationale || '');
setSource(j.source || 'heuristic');
setKeepIds(new Set());
setRegenIds(new Set());
}
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error preview');
@@ -243,20 +277,25 @@ export default function CalendarCreator({
}
}
function keepOnlySelected() {
function dropSelected() {
if (!plan?.pieces?.length) return;
if (keepIds.size === 0) {
setErr('Marca al menos una pieza o un día para quedarte solo con eso.');
if (regenIds.size === 0) {
setErr('Marca las piezas que quieres eliminar del preview.');
return;
}
setPlan(keepOnlyPieces(plan, keepIds));
const keep = new Set(
(plan.pieces || []).filter((p) => !regenIds.has(p.id)).map((p) => p.id)
);
setPlan(keepOnlyPieces(plan, keep));
setRationale(
`Selección manual: te quedas con ${keepIds.size} pieza(s). Guarda el mes cuando quieras.`
`Eliminadas ${regenIds.size} pieza(s) del preview. Guarda el mes cuando quieras.`
);
setSource('seleccion');
setRegenIds(new Set());
setErr(null);
}
async function savePlan(force = false) {
if (!plan) return;
setBusy(true);
@@ -415,7 +454,7 @@ export default function CalendarCreator({
setDayZoom(null);
setForm(null);
}}
className="inline-flex items-center gap-2 rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-gray-800"
className="ml-auto inline-flex items-center gap-2 rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-gray-800"
>
<CalendarPlus className="h-4 w-4" />
Crear nuevo calendario
@@ -506,21 +545,21 @@ export default function CalendarCreator({
</button>
<button
type="button"
disabled={busy || !plan?.pieces?.length || keepIds.size === 0}
onClick={() => void runPreview('regen_unkept')}
title="Conserva las ✓ y pide ángulos nuevos para el resto"
disabled={busy || !plan?.pieces?.length || regenIds.size === 0}
onClick={() => void runPreview('regen_selected')}
title="Regenera solo las piezas marcadas (con instrucciones opcionales)"
className="inline-flex items-center gap-1.5 rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-sky-700 disabled:opacity-60"
>
Regenerar no marcadas
Regenerar marcadas
</button>
<button
type="button"
disabled={busy || keepIds.size === 0}
onClick={() => keepOnlySelected()}
title="Borra del preview todo lo que no esté marcado"
disabled={busy || regenIds.size === 0}
onClick={() => dropSelected()}
title="Quita del preview las piezas marcadas"
className="rounded-lg border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-900 disabled:opacity-60"
>
Solo quedarme con ✓
Eliminar marcadas
</button>
<button
type="button"
@@ -543,13 +582,143 @@ export default function CalendarCreator({
) : null}
<span className="text-[11px] text-gray-500">
{plan?.pieces?.length || 0} piezas
{keepIds.size ? ` · ${keepIds.size} ✓` : ''}
{regenIds.size ? ` · ${regenIds.size} ✓` : ''}
{source ? ` · ${source}` : ''}
</span>
</div>
{showGenPanel ? (
<div className="rounded-xl border border-violet-200 bg-violet-50/50 p-4 space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-bold uppercase tracking-wider text-violet-800">
Opciones de generación
</p>
<button
type="button"
className="text-[11px] text-violet-700 underline"
onClick={() => setShowGenPanel(false)}
>
Ocultar
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{(
[
['posts', 'Posts'],
['stories', 'Stories'],
['reels', 'Reels'],
['carruseles', 'Carruseles'],
] as const
).map(([key, label]) => (
<label key={key} className="text-[11px] text-gray-700">
{label}
<input
type="number"
min={0}
max={40}
value={genOptions[key]}
onChange={(e) =>
setGenOptions((o) => ({
...o,
[key]: Number(e.target.value) || 0,
}))
}
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1 text-sm"
/>
</label>
))}
</div>
<div>
<p className="text-[11px] font-semibold text-gray-700 mb-1">
Días de la semana
</p>
<div className="flex flex-wrap gap-1.5">
{WEEKDAY_LABELS.map(({ key, label }) => {
const on = genOptions.weekdays.includes(key);
return (
<button
key={key}
type="button"
onClick={() =>
setGenOptions((o) => ({
...o,
weekdays: on
? o.weekdays.filter((d) => d !== key)
: [...o.weekdays, key].sort((a, b) => a - b),
}))
}
className={`rounded-full px-2.5 py-1 text-[11px] font-semibold border ${
on
? 'bg-violet-600 text-white border-violet-600'
: 'bg-white text-gray-600 border-gray-200'
}`}
>
{label}
</button>
);
})}
</div>
</div>
<label className="flex items-start gap-2 text-[11px] text-gray-800">
<input
type="checkbox"
className="mt-0.5"
checked={genOptions.allowSameDayMix}
onChange={(e) =>
setGenOptions((o) => ({
...o,
allowSameDayMix: e.target.checked,
}))
}
/>
<span>
Permitir <strong>story + post</strong> o{' '}
<strong>reel + story</strong> el mismo día
</span>
</label>
<label className="block text-[11px] text-gray-700">
Instrucciones para generar el mes
<textarea
value={genOptions.instructions || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
instructions: e.target.value,
}))
}
rows={2}
placeholder="Ej: enfocar Black Friday la 3ª semana; tono cercano; 2 stories encuesta…"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
/>
</label>
<label className="block text-[11px] text-gray-700">
Instrucciones solo al regenerar marcadas
<textarea
value={genOptions.regenInstructions || ''}
onChange={(e) =>
setGenOptions((o) => ({
...o,
regenInstructions: e.target.value,
}))
}
rows={2}
placeholder="Ej: más CTA WhatsApp; menos educativo; urgencia…"
className="mt-0.5 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs"
/>
</label>
</div>
) : (
<button
type="button"
className="text-[11px] text-violet-700 underline"
onClick={() => setShowGenPanel(true)}
>
Mostrar opciones de generación
</button>
)}
{rationale ? (
<div className="rounded-xl border border-violet-200 bg-violet-50/70 px-4 py-3 text-xs text-violet-950 leading-relaxed">
<div className="rounded-xl border border-violet-200 bg-violet-50/70 px-4 py-3 text-xs text-violet-950 leading-relaxed whitespace-pre-wrap">
<p className="font-bold uppercase tracking-wider text-violet-700 mb-1">
Criterio del preview
</p>
@@ -557,10 +726,10 @@ export default function CalendarCreator({
</div>
) : (
<p className="text-xs text-gray-500">
Marca ✓ las piezas o días que te gustan. Luego:{' '}
<strong>Regenerar no marcadas</strong> (v2 solo de las
malas) o <strong>Solo quedarme con ✓</strong> (borra el
resto). También puedes editar/borrar día a día.
Marca ✓ las piezas/días a <strong>regenerar</strong>. Luego{' '}
<strong>Regenerar marcadas</strong> (con instrucciones) o{' '}
<strong>Eliminar marcadas</strong>. El resto se conserva.
No se guardan versiones intermedias hasta «Guardar mes».
</p>
)}
@@ -593,7 +762,7 @@ export default function CalendarCreator({
const key = `${month}-${String(day).padStart(2, '0')}`;
const hits = byDate.get(key) || [];
const dayKept =
hits.length > 0 && hits.every((p) => keepIds.has(p.id));
hits.length > 0 && hits.every((p) => regenIds.has(p.id));
return (
<div
key={key}
@@ -615,13 +784,13 @@ export default function CalendarCreator({
{hits.length > 0 ? (
<label
className="flex items-center gap-0.5 text-[9px] text-emerald-700 cursor-pointer"
title="Marcar/desmarcar todo el día"
title="Marcar/desmarcar el día para regenerar"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={dayKept}
onChange={() => toggleKeepDay(key)}
onChange={() => toggleRegenDay(key)}
className="h-3 w-3"
/>
día
@@ -640,14 +809,14 @@ export default function CalendarCreator({
<span
key={p.id}
className={`truncate rounded px-1 py-0.5 text-[9px] font-semibold text-white ${
keepIds.has(p.id)
regenIds.has(p.id)
? 'outline outline-1 outline-offset-[-1px] outline-white'
: ''
}`}
style={{ background: colorFor(p) }}
title={p.title}
>
{keepIds.has(p.id) ? '✓ ' : ''}
{regenIds.has(p.id) ? '✓ ' : ''}
{p.title}
</span>
))}
@@ -712,7 +881,7 @@ export default function CalendarCreator({
<div
key={p.id}
className={`rounded-xl border p-3 space-y-2 ${
keepIds.has(p.id)
regenIds.has(p.id)
? 'border-emerald-300 bg-emerald-50/40'
: 'border-gray-200'
}`}
@@ -722,8 +891,8 @@ export default function CalendarCreator({
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 cursor-pointer">
<input
type="checkbox"
checked={keepIds.has(p.id)}
onChange={() => toggleKeep(p.id)}
checked={regenIds.has(p.id)}
onChange={() => toggleRegen(p.id)}
/>
{p.title}
</label>
@@ -740,6 +909,39 @@ export default function CalendarCreator({
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap">
{p.body || p.hook}
</p>
{p.interactive_story &&
p.interactive_story !== 'none' ? (
<p className="text-[10px] font-semibold text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-2 py-1">
Story interactiva: {p.interactive_story} · Meta API
no publica stickers — briefing de producción
</p>
) : null}
{p.slide_copy && p.slide_copy.length > 0 ? (
<div className="rounded-lg border border-gray-100 bg-gray-50 p-2 space-y-1">
<p className="text-[10px] font-bold uppercase tracking-wider text-gray-500">
Texto por slide
</p>
{p.slide_copy.map((s) => (
<p key={s.n} className="text-[11px] text-gray-700">
<span className="font-semibold">
Slide {s.n}
{s.label ? ` · ${s.label}` : ''}:
</span>{' '}
{s.text}
</p>
))}
</div>
) : null}
{p.reel_script ? (
<details className="rounded-lg border border-violet-100 bg-violet-50/50 p-2">
<summary className="text-[10px] font-bold uppercase tracking-wider text-violet-800 cursor-pointer">
Guión / idea Reel
</summary>
<pre className="mt-1 text-[11px] text-violet-950 whitespace-pre-wrap font-sans">
{p.reel_script}
</pre>
</details>
) : null}
<div className="flex gap-2">
<button
type="button"

View File

@@ -522,16 +522,25 @@ export default function CreativePipelineBoard() {
payload: {
scheduled_at: string | null;
channels: PublishChannel[];
channel_schedules?: { instagram?: string; facebook?: string };
}
) {
if (!isAgency) return;
setBusyId(id);
setErr(null);
try {
const existing = items.find((x) => x.id === id);
let notes = existing?.notes || '';
// Strip previous schedule marker
notes = notes.replace(/\n?__4v_sched__:[^\n]*/g, '').trim();
if (payload.channel_schedules) {
notes = `${notes}\n__4v_sched__:${JSON.stringify(payload.channel_schedules)}`.trim();
}
const { channel_schedules: _cs, ...rest } = payload;
const res = await fetch('/api/creative-pipeline', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...payload }),
body: JSON.stringify({ id, ...rest, notes: notes || null }),
});
const data = await res.json();
if (!res.ok) {
@@ -1671,6 +1680,7 @@ function DetailModal({
onSaveSchedule: (payload: {
scheduled_at: string | null;
channels: PublishChannel[];
channel_schedules?: { instagram?: string; facebook?: string };
}) => void;
onSaveContent: (payload: {
title: string;
@@ -1690,6 +1700,13 @@ function DetailModal({
const [schedLocal, setSchedLocal] = useState(
toDatetimeLocalValue(item.scheduled_at)
);
const [schedIg, setSchedIg] = useState(
toDatetimeLocalValue(item.scheduled_at)
);
const [schedFb, setSchedFb] = useState(
toDatetimeLocalValue(item.scheduled_at)
);
const [sameSchedule, setSameSchedule] = useState(true);
const [chIg, setChIg] = useState(
(item.channels || ['instagram', 'facebook']).includes('instagram')
);
@@ -1710,7 +1727,11 @@ function DetailModal({
});
useEffect(() => {
setSchedLocal(toDatetimeLocalValue(item.scheduled_at));
const base = toDatetimeLocalValue(item.scheduled_at);
setSchedLocal(base);
setSchedIg(base);
setSchedFb(base);
setSameSchedule(true);
const ch = item.channels || ['instagram', 'facebook'];
setChIg(ch.includes('instagram'));
setChFb(ch.includes('facebook'));
@@ -1743,9 +1764,33 @@ function DetailModal({
const channels: PublishChannel[] = [];
if (chIg) channels.push('instagram');
if (chFb) channels.push('facebook');
const igIso = schedIg ? new Date(schedIg).toISOString() : null;
const fbIso = schedFb ? new Date(schedFb).toISOString() : null;
const primary =
sameSchedule || !chIg || !chFb
? schedLocal
? new Date(schedLocal).toISOString()
: igIso || fbIso
: [igIso, fbIso]
.filter(Boolean)
.sort()[0] || null;
onSaveSchedule({
scheduled_at: schedLocal ? new Date(schedLocal).toISOString() : null,
channels: channels.length ? channels : ['instagram', 'facebook'],
scheduled_at: primary,
channels: channels.length ? channels : ['instagram'],
// Horarios distintos: el worker usa scheduled_at (más temprano);
// se anota en notes vía API si el backend lo soporta.
...(sameSchedule || !(chIg && chFb)
? {}
: {
channel_schedules: {
...(chIg && igIso ? { instagram: igIso } : {}),
...(chFb && fbIso ? { facebook: fbIso } : {}),
},
}),
} as {
scheduled_at: string | null;
channels: PublishChannel[];
channel_schedules?: { instagram?: string; facebook?: string };
});
}
@@ -2000,15 +2045,6 @@ function DetailModal({
<p className="text-xs font-bold uppercase tracking-wider text-sky-900">
Programación (solo agencia)
</p>
<label className="block text-xs text-gray-700">
Fecha y hora
<input
type="datetime-local"
value={schedLocal}
onChange={(e) => setSchedLocal(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-900"
/>
</label>
<div className="flex flex-wrap gap-3 text-xs text-gray-800">
<label className="inline-flex items-center gap-1.5">
<input
@@ -2027,6 +2063,66 @@ function DetailModal({
Facebook
</label>
</div>
{chIg && chFb ? (
<label className="flex items-start gap-2 text-[11px] text-gray-800">
<input
type="checkbox"
className="mt-0.5"
checked={sameSchedule}
onChange={(e) => {
const on = e.target.checked;
setSameSchedule(on);
if (on) {
setSchedIg(schedLocal);
setSchedFb(schedLocal);
}
}}
/>
<span>
Publicar en el <strong>mismo horario</strong> en IG y FB
</span>
</label>
) : null}
{sameSchedule || !(chIg && chFb) ? (
<label className="block text-xs text-gray-700">
Fecha y hora
<input
type="datetime-local"
value={schedLocal}
onChange={(e) => {
setSchedLocal(e.target.value);
setSchedIg(e.target.value);
setSchedFb(e.target.value);
}}
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-900"
/>
</label>
) : (
<div className="grid gap-2">
{chIg ? (
<label className="block text-xs text-gray-700">
Instagram · fecha y hora
<input
type="datetime-local"
value={schedIg}
onChange={(e) => setSchedIg(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-900"
/>
</label>
) : null}
{chFb ? (
<label className="block text-xs text-gray-700">
Facebook · fecha y hora
<input
type="datetime-local"
value={schedFb}
onChange={(e) => setSchedFb(e.target.value)}
className="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-sm text-gray-900"
/>
</label>
) : null}
</div>
)}
{item.publish_status && item.publish_status !== 'idle' ? (
<p className="text-[11px] text-gray-600">
Worker: <strong>{item.publish_status}</strong>

View File

@@ -21,6 +21,13 @@ import {
type PipelineCalendarRow,
} from '@/lib/pipeline-calendar';
/** Texto concreto por slide (carrusel / story multi). */
export type RrssSlideCopy = {
n: number;
label?: string;
text: string;
};
export type RrssPiece = {
id: string;
date: string;
@@ -38,7 +45,17 @@ export type RrssPiece = {
meta_line?: string;
kind?: string;
color?: string;
/** @deprecated prefer slide_copy */
slides?: string[];
/** Copy estructurado: Slide 1 hook, Slide 2 por qué… */
slide_copy?: RrssSlideCopy[];
/** Guión / idea desarrollada (Reels). */
reel_script?: string;
/**
* Story interactiva: encuesta | quiz | caja | slider | none
* El publish Meta no automatiza stickers; esto es briefing de producción.
*/
interactive_story?: 'encuesta' | 'quiz' | 'caja' | 'slider' | 'none' | string;
pipeline_id?: string;
cover_path?: string | null;
slide_paths?: string[];

View File

@@ -0,0 +1,74 @@
/**
* Opciones de generación del calendario editorial (UI → preview API → strategist).
*/
export type WeekdayKey = 1 | 2 | 3 | 4 | 5 | 6 | 0; // Lun=1 … Dom=0 (JS getUTCDay)
export type CalendarGenOptions = {
/** Nº de posts feed (Post estático). */
posts: number;
/** Nº de Stories. */
stories: number;
/** Nº de Reels. */
reels: number;
/** Nº de Carruseles. */
carruseles: number;
/**
* Días de la semana permitidos (JS: 0=Dom … 6=Sáb).
* Por defecto Lun–Vie.
*/
weekdays: number[];
/**
* Permite story+post o reel+story el mismo día
* (reparte slots sin forzar 1 pieza/día).
*/
allowSameDayMix: boolean;
/** Instrucciones libres del operador para la IA / estrategia. */
instructions?: string;
/**
* Instrucciones solo para piezas marcadas a regenerar
* (modo regen_selected).
*/
regenInstructions?: string;
};
export const DEFAULT_GEN_OPTIONS: CalendarGenOptions = {
posts: 4,
stories: 8,
reels: 6,
carruseles: 4,
weekdays: [1, 2, 3, 4, 5],
allowSameDayMix: true,
instructions: '',
regenInstructions: '',
};
export function normalizeGenOptions(
raw: Partial<CalendarGenOptions> | null | undefined
): CalendarGenOptions {
const base = { ...DEFAULT_GEN_OPTIONS, ...(raw || {}) };
const clamp = (n: unknown, max = 40) =>
Math.max(0, Math.min(max, Math.round(Number(n) || 0)));
const weekdays = Array.isArray(base.weekdays)
? [...new Set(base.weekdays.map((d) => Number(d)).filter((d) => d >= 0 && d <= 6))]
: DEFAULT_GEN_OPTIONS.weekdays;
return {
posts: clamp(base.posts),
stories: clamp(base.stories),
reels: clamp(base.reels),
carruseles: clamp(base.carruseles),
weekdays: weekdays.length ? weekdays : DEFAULT_GEN_OPTIONS.weekdays,
allowSameDayMix: base.allowSameDayMix !== false,
instructions: String(base.instructions || '').trim().slice(0, 4000),
regenInstructions: String(base.regenInstructions || '').trim().slice(0, 4000),
};
}
export const WEEKDAY_LABELS: { key: number; label: string }[] = [
{ key: 1, label: 'Lun' },
{ key: 2, label: 'Mar' },
{ key: 3, label: 'Mié' },
{ key: 4, label: 'Jue' },
{ key: 5, label: 'Vie' },
{ key: 6, label: 'Sáb' },
{ key: 0, label: 'Dom' },
];

View File

@@ -16,9 +16,12 @@ import {
monthTrendBeats,
strategistSystemPrompt,
strategyMonthPreview,
type BuildSlotsOpts,
type PublishedNetworkPost,
} from '@/lib/calendar-strategy';
import type { Brandbook } from '@/lib/brandbook';
import type { CalendarGenOptions } from '@/lib/calendar-gen-options';
import { normalizeGenOptions } from '@/lib/calendar-gen-options';
export {
brandBrief,
@@ -37,6 +40,8 @@ export type GeneratePreviewInput = {
publishedPosts?: PublishedNetworkPost[];
/** Títulos / hooks a no repetir (piezas que el usuario conserva). */
avoidTitles?: string[];
/** Cantidades, días, mix e instrucciones del operador. */
genOptions?: Partial<CalendarGenOptions> | null;
};
export type GeneratePreviewResult = {
@@ -84,6 +89,7 @@ async function anthropicStrategist(
input.brandbook,
input.publishedPosts || []
);
const go = normalizeGenOptions(input.genOptions);
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const prompt = strategistSystemPrompt({
ym: input.ym,
@@ -91,14 +97,22 @@ async function anthropicStrategist(
dna,
trends,
avoidTitles: input.avoidTitles || [],
genOptions: go,
});
const minPieces = Math.max(
3,
Math.min(
8,
go.posts + go.stories + go.reels + go.carruseles
)
);
try {
const Anthropic = (await import('@anthropic-ai/sdk')).default;
const client = new Anthropic({ apiKey: key });
const msg = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4500,
max_tokens: 8000,
messages: [{ role: 'user', content: prompt }],
});
const text = msg.content
@@ -110,7 +124,7 @@ async function anthropicStrategist(
rationale?: string;
pieces?: Array<Partial<RrssPiece> & { objective_metric?: string }>;
};
if (!Array.isArray(parsed.pieces) || parsed.pieces.length < 8) {
if (!Array.isArray(parsed.pieces) || parsed.pieces.length < minPieces) {
return fallback;
}
@@ -125,6 +139,15 @@ async function anthropicStrategist(
const format = String(p.format || 'Post');
const kind = kindOf(format, p.kind);
const platform = String(p.platform || 'instagram').toLowerCase();
const slide_copy = Array.isArray(p.slide_copy)
? p.slide_copy
.map((s, i) => ({
n: Number((s as { n?: number }).n) || i + 1,
label: String((s as { label?: string }).label || '') || undefined,
text: String((s as { text?: string }).text || '').trim(),
}))
.filter((s) => s.text)
: undefined;
return {
id: newPieceId(date, title),
date,
@@ -142,11 +165,18 @@ async function anthropicStrategist(
status: 'planned',
kind,
color: colorFor(format, kind, platform),
slide_copy: slide_copy?.length ? slide_copy : undefined,
reel_script: p.reel_script
? String(p.reel_script).slice(0, 6000)
: undefined,
interactive_story: p.interactive_story
? String(p.interactive_story).slice(0, 40)
: undefined,
} as RrssPiece;
})
.filter(Boolean) as RrssPiece[];
if (pieces.length < 8) return fallback;
if (pieces.length < minPieces) return fallback;
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`;
plan.hero.chips = [
@@ -177,11 +207,27 @@ export async function generateMonthPreview(
}))
.filter((p) => (p.pieces?.length || 0) >= 3);
const go = normalizeGenOptions(input.genOptions);
const slotOpts: BuildSlotsOpts & {
instructions?: string;
regenInstructions?: string;
} = {
posts: go.posts,
stories: go.stories,
reels: go.reels,
carruseles: go.carruseles,
weekdays: go.weekdays,
allowSameDayMix: go.allowSameDayMix,
instructions: go.instructions,
regenInstructions: go.regenInstructions,
};
const strategist = strategyMonthPreview({
...input,
history: cleanHistory,
brandbook: input.brandbook,
publishedPosts: input.publishedPosts || [],
genOptions: slotOpts,
});
const fallback: GeneratePreviewResult = {
plan: strategist.plan,
@@ -194,6 +240,7 @@ export async function generateMonthPreview(
...input,
history: cleanHistory,
publishedPosts: input.publishedPosts || [],
genOptions: go,
},
fallback
);

View File

@@ -15,6 +15,7 @@ import {
brandbookToPrompt,
type Brandbook,
} from '@/lib/brandbook';
import { resolveBrandSlug } from '@/lib/tenant-brand-aliases';
/** Publicación real en redes (pipeline / IG sync) usada como señal creativa. */
export type PublishedNetworkPost = {
@@ -179,13 +180,17 @@ export function monthTrendBeats(ym: string, vertical: string): TrendBeat[] {
];
}
function weekdaysInMonth(ym: string): string[] {
function weekdaysInMonth(
ym: string,
allowed: number[] = [1, 2, 3, 4, 5]
): string[] {
const allow = new Set(allowed);
const [y, m] = ym.split('-').map(Number);
const days: string[] = [];
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
for (let d = 1; d <= dim; d++) {
const dt = new Date(Date.UTC(y, m - 1, d));
if (dt.getUTCDay() >= 1 && dt.getUTCDay() <= 5) {
if (allow.has(dt.getUTCDay())) {
days.push(`${ym}-${String(d).padStart(2, '0')}`);
}
}
@@ -406,17 +411,40 @@ export function extractClientDna(
'Certificaciones',
'Matriculación',
],
'talento-y-deporte': [
tyd: [
'Nuevo alumno',
'Vocab / expresiones',
'Campus',
'Errores / Fix it',
'Beca deportiva',
'Campus USA',
'Objeciones',
'English tips',
],
tsp: [
'Beca académica',
'Admisiones',
'Financiación',
'Campus USA',
'Casos de éxito',
],
ecp: [
'Día a día del cuidador',
'Formación',
'Empleabilidad',
'Testimonios',
'Convocatorias',
],
dtatf: [
'Convocatorias',
'Empleabilidad',
'Testimonios',
'Tips formación',
],
};
const fromBook = brandbook?.series_recurrentes?.length
? brandbook.series_recurrentes
: brandbook?.pillars_rrss || [];
const seed = fromBook.length ? fromBook : seedBySlug[slug] || [];
const seed = fromBook.length
? fromBook
: seedBySlug[resolveBrandSlug(slug)] || seedBySlug[slug] || [];
const signatureSeries = [
...seed,
...inferred.filter((s) => !seed.includes(s)),
@@ -455,22 +483,64 @@ type Slot = {
role: 'reach' | 'save' | 'chat' | 'convert' | 'authority';
};
function buildMonthSlots(ym: string, dna: ClientDna): Slot[] {
const weekdays = weekdaysInMonth(ym);
const target = Math.min(
weekdays.length,
Math.max(16, Math.round(dna.avgPerWeek * 4 * 0.85)) // quality > quantity (−15% vs legacy)
);
const mix = PLAYBOOK_2026.mix;
const counts = {
Reel: Math.round(target * mix.reel),
Carrusel: Math.round(target * mix.carrusel),
Stories: Math.round(target * mix.stories),
Post: 0,
};
counts.Post = Math.max(0, target - counts.Reel - counts.Carrusel - counts.Stories);
export type BuildSlotsOpts = {
posts?: number;
stories?: number;
reels?: number;
carruseles?: number;
weekdays?: number[];
/** Si true, se pueden apilar story+post / reel+story el mismo día. */
allowSameDayMix?: boolean;
};
// Round-robin interleave so the month isn't "all Reels then all Stories"
function buildMonthSlots(
ym: string,
dna: ClientDna,
opts?: BuildSlotsOpts
): Slot[] {
const weekdays = weekdaysInMonth(ym, opts?.weekdays || [1, 2, 3, 4, 5]);
if (!weekdays.length) return [];
const hasExplicit =
opts &&
(opts.posts != null ||
opts.stories != null ||
opts.reels != null ||
opts.carruseles != null);
let counts: Record<Slot['format'], number>;
let target: number;
if (hasExplicit) {
counts = {
Reel: Math.max(0, Math.round(opts?.reels ?? 0)),
Carrusel: Math.max(0, Math.round(opts?.carruseles ?? 0)),
Stories: Math.max(0, Math.round(opts?.stories ?? 0)),
Post: Math.max(0, Math.round(opts?.posts ?? 0)),
};
target =
counts.Reel + counts.Carrusel + counts.Stories + counts.Post;
} else {
target = Math.min(
weekdays.length,
Math.max(16, Math.round(dna.avgPerWeek * 4 * 0.85))
);
const mix = PLAYBOOK_2026.mix;
counts = {
Reel: Math.round(target * mix.reel),
Carrusel: Math.round(target * mix.carrusel),
Stories: Math.round(target * mix.stories),
Post: 0,
};
counts.Post = Math.max(
0,
target - counts.Reel - counts.Carrusel - counts.Stories
);
target =
counts.Reel + counts.Carrusel + counts.Stories + counts.Post;
}
// Round-robin interleave
const bags: Record<Slot['format'], number> = { ...counts };
const formats: Slot['format'][] = ['Reel', 'Carrusel', 'Stories', 'Post'];
const queue: Array<Slot['format']> = [];
@@ -487,20 +557,53 @@ function buildMonthSlots(ym: string, dna: ClientDna): Slot[] {
if (!added) break;
}
const convertEvery = Math.max(4, Math.floor(target / 3));
const allowMix = opts?.allowSameDayMix !== false;
const convertEvery = Math.max(4, Math.floor(Math.max(1, target) / 3));
const slots: Slot[] = [];
const step = Math.max(1, Math.floor(weekdays.length / Math.max(1, queue.length)));
const step = Math.max(
1,
Math.floor(weekdays.length / Math.max(1, queue.length))
);
let dayIdx = 0;
const countOnDay = (d: string) => slots.filter((s) => s.date === d).length;
const formatsOnDay = (d: string) =>
new Set(slots.filter((s) => s.date === d).map((s) => s.format));
/** Compat: story+post o reel+story el mismo día; evita 2 reels / 2 posts. */
function dayAccepts(date: string, format: Slot['format']): boolean {
const n = countOnDay(date);
if (n === 0) return true;
if (!allowMix) return false;
if (n >= 2) return false;
const existing = [...formatsOnDay(date)][0];
if (!existing) return true;
const pair = new Set([existing, format]);
if (pair.has('Stories') && pair.has('Post')) return true;
if (pair.has('Reel') && pair.has('Stories')) return true;
return false;
}
for (let qi = 0; qi < queue.length; qi++) {
const format = queue[qi];
const date = weekdays[Math.min(dayIdx, weekdays.length - 1)];
dayIdx += step;
if (dayIdx >= weekdays.length) dayIdx = qi % weekdays.length;
// Prefer unused weekdays
let d = date;
if (slots.some((s) => s.date === d)) {
d = weekdays.find((w) => !slots.some((s) => s.date === w)) || date;
let d =
weekdays[Math.min(dayIdx, weekdays.length - 1)] || weekdays[0];
dayIdx = (dayIdx + step) % weekdays.length;
if (!dayAccepts(d, format)) {
const free = weekdays.find((w) => dayAccepts(w, format));
if (free) d = free;
else if (allowMix) {
// Forzar mix: elige el día con menos carga que acepte o el de menor carga
const sorted = [...weekdays].sort(
(a, b) => countOnDay(a) - countOnDay(b)
);
d = sorted.find((w) => dayAccepts(w, format)) || sorted[0];
} else {
const empty = weekdays.find((w) => countOnDay(w) === 0);
d = empty || d;
}
}
const isConvert =
qi > 0 &&
qi % convertEvery === 0 &&
@@ -653,6 +756,17 @@ function pickAngleTitle(
return `${prefix} · ${core}`.slice(0, 140);
}
type AngleOut = {
title: string;
hook: string;
body: string;
cta: string;
pillar: string;
slide_copy?: { n: number; label?: string; text: string }[];
reel_script?: string;
interactive_story?: string;
};
/** Creative angle factory — NEW titles, DNA-aligned. */
function inventAngle(
dna: ClientDna,
@@ -661,7 +775,7 @@ function inventAngle(
index: number,
/** Extra offset only for title bank rotation (dedupe). */
bankOffset = 0
): { title: string; hook: string; body: string; cta: string; pillar: string } {
): AngleOut {
const series =
dna.signatureSeries[index % Math.max(1, dna.signatureSeries.length)] ||
'Educativo';
@@ -682,15 +796,20 @@ function inventAngle(
? title
: `STORIES · ${pillar}: encuesta “¿este mes o enero?”`,
hook: 'Una pregunta. Tu respuesta abre el DM.',
body: `Conversión ${brand}. ADN: ${pillar}. Sticker encuesta/caja → DM. KPI: mensajes. Sin spam BF genérico.`,
body: `Conversión ${brand}. ADN: ${pillar}. Sticker encuesta/caja → DM. Meta API no publica stickers: briefing de producción.`,
cta: 'Responde y te orientamos',
pillar: 'conversion',
interactive_story: 'encuesta',
slide_copy: [
{ n: 1, label: 'Hook', text: 'Una pregunta. Tu respuesta abre el DM.' },
{ n: 2, label: 'Sticker', text: 'Encuesta: ¿Este mes o más adelante? → DM.' },
],
};
}
return {
title,
hook: 'La diferencia entre “lo pienso” y “ya estoy dentro”.',
body: `Conversión útil. 3 motivos + prueba social + CTA test/matricula. ADN: ${pillar}. Tendencia: ${beat.why}`,
body: `Conversión útil. 3 motivos + prueba social + CTA test/matrícula. ADN: ${pillar}. Tendencia: ${beat.why}`,
cta: 'Link en bio / test de nivel',
pillar: 'conversion',
};
@@ -699,6 +818,14 @@ function inventAngle(
const title = pickAngleTitle(series, beat, bankIndex, slot.format);
if (slot.format === 'Reel') {
const reel_script = [
`IDEA: ${title}`,
`Hook (0–3s): ${hookStyle}`,
`Desarrollo (3–20s): micro-lección «${series}». Tendencia: ${beat.label}.`,
`Payoff (20–28s): insight + keyword en pantalla.`,
`CTA: Comparte / guarda. Caption SEO.`,
`Producción: 9:16, subtítulos burned-in, VO clara.`,
].join('\n');
return {
title,
hook: `${hookStyle}`,
@@ -706,38 +833,54 @@ function inventAngle(
`Objetivo: ALCANCE (Reels 2026 ES).`,
`ADN: ${series}. Tendencia: ${beat.label} — ${beat.why}`,
`Estructura: hook 3s → micro-lección → CTA share/save.`,
`Texto en pantalla + caption con keyword buscable.`,
].join('\n'),
cta: 'Comparte con alguien que lo necesite',
pillar: 'reach',
reel_script,
};
}
if (slot.format === 'Carrusel') {
const slide_copy = [
{ n: 1, label: 'Hook', text: 'Guarda esto antes de que se te olvide.' },
{ n: 2, label: 'Por qué pasa esto', text: beat.why },
{ n: 3, label: 'Qué hacer', text: `Acción concreta ligada a «${series}» (${brand}).` },
{ n: 4, label: 'Ejemplo', text: 'Caso breve o contraste antes/después.' },
{ n: 5, label: 'CTA', text: 'Guarda + envía a alguien a quien le sirva.' },
];
return {
title: title.replace(/^REEL ·/, 'CARRUSEL ·').replace(/^POST ·/, 'CARRUSEL ·'),
hook: 'Guarda esto antes de que se te olvide.',
body: [
`Objetivo: GUARDADOS + shares.`,
`ADN: ${series}. ${beat.why}`,
`5 slides: hook polémico → valor → test “envía a un amigo”.`,
`5 slides: hook → por qué → qué hacer → ejemplo → CTA.`,
].join('\n'),
cta: 'Guarda + envía a tu compañero de estudio',
pillar: 'saves',
slide_copy,
};
}
if (slot.format === 'Stories') {
const interactive =
index % 3 === 0 ? 'encuesta' : index % 3 === 1 ? 'quiz' : 'caja';
return {
title: title.replace(/^(REEL|CARRUSEL|POST) ·/, 'STORIES ·'),
hook: 'Tú eliges: A o B',
body: [
`Objetivo: REPLIES (+88% conversación Stories 2026).`,
`ADN: ${series}. Sticker encuesta / quiz / caja.`,
`ADN: ${series}. Sticker ${interactive}.`,
`Producción: Meta no publica stickers vía API — diseñar en IG/Canva y subir media.`,
beat.label,
].join('\n'),
cta: 'Responde a la story',
pillar: 'stories',
interactive_story: interactive,
slide_copy: [
{ n: 1, label: 'Visual', text: 'Tú eliges: A o B' },
{ n: 2, label: 'Sticker', text: `Tipo: ${interactive}. Recoger respuesta → DM.` },
],
};
}
@@ -745,7 +888,7 @@ function inventAngle(
title: title.replace(/^(REEL|CARRUSEL|STORIES) ·/, 'POST ·'),
hook: `Lo que nadie te explica de ${series.toLowerCase()}.`,
body: [
`Feed autoridad + SEO caption (“cómo se dice…”, “diferencia entre…”).`,
`Feed autoridad + SEO caption.`,
`ADN ${brand}: ${series}.`,
`Tendencia: ${beat.why}`,
].join('\n'),
@@ -769,6 +912,7 @@ export function strategyMonthPreview(input: {
history: RrssMonthPlan[];
brandbook?: Brandbook | null;
publishedPosts?: PublishedNetworkPost[];
genOptions?: BuildSlotsOpts & { instructions?: string };
}): StrategyPreviewResult {
const dna = extractClientDna(
input.slug,
@@ -778,7 +922,7 @@ export function strategyMonthPreview(input: {
input.publishedPosts || []
);
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
const slots = buildMonthSlots(input.ym, dna);
const slots = buildMonthSlots(input.ym, dna, input.genOptions);
const pieces: RrssPiece[] = [];
const usedCores = new Set<string>();
@@ -824,6 +968,9 @@ export function strategyMonthPreview(input: {
kind,
color: colorFor(slot.format, kind),
objective_metric: useBeat.kpi,
slide_copy: angle.slide_copy,
reel_script: angle.reel_script,
interactive_story: angle.interactive_story,
});
});
@@ -832,25 +979,34 @@ export function strategyMonthPreview(input: {
plan.hero.chips = [
`${pieces.length} piezas`,
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
`${pieces.filter((p) => p.format === 'Carrusel').length} carruseles`,
`${pieces.filter((p) => p.format === 'Stories').length} Stories`,
`${pieces.filter((p) => p.format === 'Post').length} Posts`,
dna.networkPostsUsed ? `${dna.networkPostsUsed} pubs hist.` : 'strategist',
];
plan.pieces = pieces;
const go = input.genOptions;
const rationale = [
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
`Fuentes ADN: ${dna.calendarMonthsUsed} mes(es) de calendario editorial · ${dna.networkPostsUsed} publicación(es) reales en redes · ${dna.brandbookPrompt ? 'brandbook' : 'sin brandbook'}.`,
`Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
go
? `Mix pedido: ${go.reels ?? '—'} Reels · ${go.carruseles ?? '—'} Carrusel · ${go.stories ?? '—'} Stories · ${go.posts ?? '—'} Posts · mix mismo día: ${go.allowSameDayMix !== false ? 'sí' : 'no'}.`
: `Playbook ES 2026 (IAB): menos posts, más eficiencia · Reels alcance · Carrusel guardados · Stories conversación.`,
`Tendencias ${monthLabel(input.ym)}: ${trends
.slice(0, 4)
.map((t) => t.label)
.join(' · ')}.`,
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post}.`,
`Conversión (~${pieces.filter((p) => p.pillar === 'conversion').length} piezas) alineada a ${dna.conversionPillars.join('/')}, no spam genérico.`,
go?.instructions
? `Instrucciones operador: ${go.instructions.slice(0, 400)}`
: '',
dna.brandbookPrompt
? 'Brandbook activo como constraint creativo.'
: dna.voiceNotes,
].join(' ');
]
.filter(Boolean)
.join(' ');
return { plan, rationale, source: 'strategy', trends, dna };
}
@@ -861,6 +1017,10 @@ export function strategistSystemPrompt(input: {
dna: ClientDna;
trends: TrendBeat[];
avoidTitles?: string[];
genOptions?: BuildSlotsOpts & {
instructions?: string;
regenInstructions?: string;
};
}): string {
const netBlock =
input.dna.networkSamples.length > 0
@@ -875,6 +1035,16 @@ export function strategistSystemPrompt(input: {
.map((t) => `- ${t}`)
.join('\n')}\n`
: '';
const go = input.genOptions;
const mixBlock = go
? `Mix EXACTO pedido: ${go.reels ?? 0} Reels, ${go.carruseles ?? 0} Carrusel, ${go.stories ?? 0} Stories, ${go.posts ?? 0} Posts. Días permitidos (JS weekday 0=Dom): ${(go.weekdays || [1, 2, 3, 4, 5]).join(',')}. Mix mismo día story+post o reel+story: ${go.allowSameDayMix !== false ? 'permitido' : 'prohibido'}.`
: `Mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post.`;
const instrBlock = [
go?.instructions ? `\nINSTRUCCIONES OPERADOR:\n${go.instructions}\n` : '',
go?.regenInstructions
? `\nINSTRUCCIONES SOLO PARA REGENERADAS:\n${go.regenInstructions}\n`
: '',
].join('');
return `Eres Head of Social de 4Ventures. Crea un calendario NUEVO para ${input.label} (${input.ym}).
NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo:
1) Zona de marca / brandbook
@@ -884,10 +1054,12 @@ ADN series: ${input.dna.signatureSeries.join(', ')}.
Voz: ${input.dna.brand.tone}. Vertical: ${input.dna.brand.vertical}.
Volumen guía: ~${input.dna.avgPerWeek} piezas/semana · Stories ratio hist. ${(input.dna.storiesRatio * 100).toFixed(0)}%.
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
${netBlock}${avoidBlock}
${netBlock}${avoidBlock}${instrBlock}
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
${mixBlock}
Tendencias del mes:
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric}] }
Reglas: títulos creativos y específicos (nuevos); rota series del ADN; mix ~28% Reel, 18% Carrusel, 38% Stories, resto Post; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.`;
Devuelve SOLO JSON { rationale, pieces:[{date,time,platform,format,pillar,title,hook,body,cta,objective_metric,slide_copy,reel_script,interactive_story}] }
Reglas: títulos creativos y específicos (nuevos); rota series del ADN; horas engagement (Reels noche ~19:30, Stories mañana, Carrusel mediodía); KPI por pieza; español; sin "POST 1 · Black Friday" vacío; respeta claims prohibidos del brandbook.
Para Carrusel: slide_copy = [{n,label,text}] (Slide 1 hook, 2 por qué, …). Para Reel: reel_script = guión desarrollado. Para Stories interactivas: interactive_story = encuesta|quiz|caja|slider y explica el sticker en body (Meta API no publica stickers).`;
}

View File

@@ -190,13 +190,32 @@ export async function processDuePipelinePublishes(opts?: {
// Publish IG then FB in two steps, persisting each Meta id immediately
// so a crash / FB failure cannot create a duplicate IG on retry.
const channels = normalizeChannels(item.channels);
// Horarios por canal opcionales en notes: __4v_sched__:{"instagram":"...","facebook":"..."}
let channelSched: { instagram?: string; facebook?: string } = {};
const schedMatch = String(item.notes || '').match(
/__4v_sched__:(\{[^}\n]+\})/
);
if (schedMatch) {
try {
channelSched = JSON.parse(schedMatch[1]) as typeof channelSched;
} catch {
channelSched = {};
}
}
const nowMs = Date.now();
const channelDue = (ch: 'instagram' | 'facebook') => {
const iso = channelSched[ch];
if (!iso) return true; // usa scheduled_at global (ya filtrado)
const t = Date.parse(iso);
return !Number.isNaN(t) && t <= nowMs;
};
const errors: string[] = [];
let igMediaId: string | null = item.meta_ig_media_id || null;
let fbPostId: string | null = item.meta_fb_post_id || null;
let igPermalink: string | null = null;
let fbPermalink: string | null = null;
if (channels.includes('instagram') && !igMediaId) {
if (channels.includes('instagram') && !igMediaId && channelDue('instagram')) {
const ig = await publishPipelineItem(item, { skipFb: true });
if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) {
await patchItem(base, headers, item.id, {
@@ -217,7 +236,12 @@ export async function processDuePipelinePublishes(opts?: {
}
}
if (channels.includes('facebook') && !fbPostId && errors.length === 0) {
if (
channels.includes('facebook') &&
!fbPostId &&
errors.length === 0 &&
channelDue('facebook')
) {
const fb = await publishPipelineItem(
{ ...item, meta_ig_media_id: igMediaId },
{ skipIg: true }
@@ -235,8 +259,28 @@ export async function processDuePipelinePublishes(opts?: {
}
}
const wantIg = channels.includes('instagram');
const wantFb = channels.includes('facebook');
const wantIg = channels.includes('instagram') && channelDue('instagram');
const wantFb = channels.includes('facebook') && channelDue('facebook');
// Si un canal aún no toca por horario, no marcar fallo: reintentará el cron.
const igPending =
channels.includes('instagram') && !channelDue('instagram') && !igMediaId;
const fbPending =
channels.includes('facebook') && !channelDue('facebook') && !fbPostId;
if ((igPending || fbPending) && errors.length === 0) {
await patchItem(base, headers, item.id, {
publish_status: 'queued',
publish_error: null,
meta_ig_media_id: igMediaId,
meta_fb_post_id: fbPostId,
});
results.push({
id: item.id,
title: item.title,
ok: true,
detail: 'partial_wait_channel_schedule',
});
continue;
}
const igOk = !wantIg || Boolean(igMediaId);
const fbOk = !wantFb || Boolean(fbPostId);
const ok = igOk && fbOk && errors.length === 0;