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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[];
|
||||
|
||||
74
src/lib/calendar-gen-options.ts
Normal file
74
src/lib/calendar-gen-options.ts
Normal 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' },
|
||||
];
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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).`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user