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),
|
hook: str(raw.hook, 300),
|
||||||
body: cleanBody,
|
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 {
|
return {
|
||||||
id: str(raw.id, 120) || newPieceId(date, title),
|
id: str(raw.id, 120) || newPieceId(date, title),
|
||||||
date,
|
date,
|
||||||
@@ -151,6 +161,9 @@ function normalizePiece(
|
|||||||
: /linkedin/i.test(enriched.platform || '')
|
: /linkedin/i.test(enriched.platform || '')
|
||||||
? '#7C66B8'
|
? '#7C66B8'
|
||||||
: '#2071cf',
|
: '#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 {
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
month?: string;
|
month?: string;
|
||||||
avoid_titles?: string[];
|
avoid_titles?: string[];
|
||||||
|
gen_options?: Record<string, unknown>;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
const month = String(body.month || '').slice(0, 7);
|
const month = String(body.month || '').slice(0, 7);
|
||||||
if (!/^\d{4}-\d{2}$/.test(month)) {
|
if (!/^\d{4}-\d{2}$/.test(month)) {
|
||||||
@@ -55,6 +57,9 @@ export async function POST(req: NextRequest) {
|
|||||||
.map((t) => t.trim().slice(0, 160))
|
.map((t) => t.trim().slice(0, 160))
|
||||||
.slice(0, 40)
|
.slice(0, 40)
|
||||||
: [];
|
: [];
|
||||||
|
const genOptions = (body.gen_options || body.options || null) as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| null;
|
||||||
|
|
||||||
let slug = 'cliente';
|
let slug = 'cliente';
|
||||||
const t = await restGet<{ slug?: string }[]>(
|
const t = await restGet<{ slug?: string }[]>(
|
||||||
@@ -132,6 +137,7 @@ export async function POST(req: NextRequest) {
|
|||||||
brandbook,
|
brandbook,
|
||||||
publishedPosts,
|
publishedPosts,
|
||||||
avoidTitles,
|
avoidTitles,
|
||||||
|
genOptions: genOptions || undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ export default async function CalendarioPage({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{agency ? (
|
{agency ? (
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
<CalendarCreator
|
<CalendarCreator
|
||||||
tenantLabel={ctx.label}
|
tenantLabel={ctx.label}
|
||||||
existingMonths={plans.map((p) => p.month).filter(Boolean)}
|
existingMonths={plans.map((p) => p.month).filter(Boolean)}
|
||||||
@@ -345,14 +345,14 @@ export default async function CalendarioPage({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{agency ? (
|
{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
|
<CalendarCreator
|
||||||
tenantLabel={ctx.label}
|
tenantLabel={ctx.label}
|
||||||
existingMonths={plans.map((p) => p.month).filter(Boolean)}
|
existingMonths={plans.map((p) => p.month).filter(Boolean)}
|
||||||
/>
|
/>
|
||||||
{active.month ? (
|
|
||||||
<DeleteCalendarMonthButton month={active.month} />
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<RrssMonthView
|
<RrssMonthView
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ import {
|
|||||||
keepOnlyPieces,
|
keepOnlyPieces,
|
||||||
mergeKeepAndRegenerate,
|
mergeKeepAndRegenerate,
|
||||||
} from '@/lib/calendar-generate';
|
} from '@/lib/calendar-generate';
|
||||||
|
import {
|
||||||
|
DEFAULT_GEN_OPTIONS,
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
normalizeGenOptions,
|
||||||
|
type CalendarGenOptions,
|
||||||
|
} from '@/lib/calendar-gen-options';
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
'Enero',
|
'Enero',
|
||||||
@@ -126,8 +132,12 @@ export default function CalendarCreator({
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [dayZoom, setDayZoom] = useState<string | null>(null);
|
const [dayZoom, setDayZoom] = useState<string | null>(null);
|
||||||
const [form, setForm] = useState<PieceForm | null>(null);
|
const [form, setForm] = useState<PieceForm | null>(null);
|
||||||
/** Piezas a conservar (✓) en selección parcial */
|
/** Piezas marcadas para regenerar (✓) */
|
||||||
const [keepIds, setKeepIds] = useState<Set<string>>(new Set());
|
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 monthChoices = useMemo(() => {
|
||||||
const base = ymNow();
|
const base = ymNow();
|
||||||
@@ -168,7 +178,7 @@ export default function CalendarCreator({
|
|||||||
setRationale('');
|
setRationale('');
|
||||||
setSource('');
|
setSource('');
|
||||||
}
|
}
|
||||||
setKeepIds(new Set());
|
setRegenIds(new Set());
|
||||||
setMonth(ym);
|
setMonth(ym);
|
||||||
setStep('edit');
|
setStep('edit');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -178,8 +188,8 @@ export default function CalendarCreator({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleKeep(id: string) {
|
function toggleRegen(id: string) {
|
||||||
setKeepIds((prev) => {
|
setRegenIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
else next.add(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);
|
const ids = (byDate.get(date) || []).map((p) => p.id);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
setKeepIds((prev) => {
|
setRegenIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
const allOn = ids.every((id) => next.has(id));
|
const allOn = ids.every((id) => next.has(id));
|
||||||
if (allOn) ids.forEach((id) => next.delete(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);
|
setBusy(true);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
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 keptPieces = (plan?.pieces || []).filter((p) => keepIds.has(p.id));
|
||||||
const r = await fetch('/api/calendar/preview', {
|
const r = await fetch('/api/calendar/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -212,29 +231,44 @@ export default function CalendarCreator({
|
|||||||
avoid_titles: keptPieces
|
avoid_titles: keptPieces
|
||||||
.map((p) => p.title || p.hook || '')
|
.map((p) => p.title || p.hook || '')
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
|
gen_options: {
|
||||||
|
...opts,
|
||||||
|
regenInstructions:
|
||||||
|
mode === 'regen_selected'
|
||||||
|
? opts.regenInstructions || opts.instructions
|
||||||
|
: opts.instructions,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (!r.ok) throw new Error(j.error || 'No se pudo generar preview');
|
if (!r.ok) throw new Error(j.error || 'No se pudo generar preview');
|
||||||
if (mode === 'regen_unkept' && plan?.pieces?.length && keepIds.size > 0) {
|
if (mode === 'regen_selected' && plan?.pieces?.length) {
|
||||||
if (keepIds.size >= plan.pieces.length) {
|
if (regenIds.size === 0) {
|
||||||
throw new Error(
|
throw new Error('Marca al menos una pieza (o un día) para regenerar.');
|
||||||
'Desmarca al menos una pieza (o un día) para regenerar solo las malas.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
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);
|
const merged = mergeKeepAndRegenerate(plan, j.plan, keepIds);
|
||||||
setPlan(merged);
|
setPlan(merged);
|
||||||
setRationale(
|
setRationale(
|
||||||
`${j.rationale || ''}\n\nSelección: ${keepIds.size} conservadas · ${
|
`${j.rationale || ''}
|
||||||
merged.pieces.length - keepIds.size
|
|
||||||
} regeneradas.`
|
Regeneradas: ${regenIds.size} · Conservadas: ${
|
||||||
|
keepIds.size
|
||||||
|
}.`
|
||||||
);
|
);
|
||||||
setSource(`${j.source || 'heuristic'}+parcial`);
|
setSource(`${j.source || 'heuristic'}+regen`);
|
||||||
|
setRegenIds(new Set());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setPlan(j.plan);
|
setPlan(j.plan);
|
||||||
setRationale(j.rationale || '');
|
setRationale(j.rationale || '');
|
||||||
setSource(j.source || 'heuristic');
|
setSource(j.source || 'heuristic');
|
||||||
setKeepIds(new Set());
|
setRegenIds(new Set());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr(e instanceof Error ? e.message : 'Error preview');
|
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 (!plan?.pieces?.length) return;
|
||||||
if (keepIds.size === 0) {
|
if (regenIds.size === 0) {
|
||||||
setErr('Marca al menos una pieza o un día para quedarte solo con eso.');
|
setErr('Marca las piezas que quieres eliminar del preview.');
|
||||||
return;
|
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(
|
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');
|
setSource('seleccion');
|
||||||
|
setRegenIds(new Set());
|
||||||
setErr(null);
|
setErr(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function savePlan(force = false) {
|
async function savePlan(force = false) {
|
||||||
if (!plan) return;
|
if (!plan) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -415,7 +454,7 @@ export default function CalendarCreator({
|
|||||||
setDayZoom(null);
|
setDayZoom(null);
|
||||||
setForm(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" />
|
<CalendarPlus className="h-4 w-4" />
|
||||||
Crear nuevo calendario
|
Crear nuevo calendario
|
||||||
@@ -506,21 +545,21 @@ export default function CalendarCreator({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || !plan?.pieces?.length || keepIds.size === 0}
|
disabled={busy || !plan?.pieces?.length || regenIds.size === 0}
|
||||||
onClick={() => void runPreview('regen_unkept')}
|
onClick={() => void runPreview('regen_selected')}
|
||||||
title="Conserva las ✓ y pide ángulos nuevos para el resto"
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy || keepIds.size === 0}
|
disabled={busy || regenIds.size === 0}
|
||||||
onClick={() => keepOnlySelected()}
|
onClick={() => dropSelected()}
|
||||||
title="Borra del preview todo lo que no esté marcado"
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -543,13 +582,143 @@ export default function CalendarCreator({
|
|||||||
) : null}
|
) : null}
|
||||||
<span className="text-[11px] text-gray-500">
|
<span className="text-[11px] text-gray-500">
|
||||||
{plan?.pieces?.length || 0} piezas
|
{plan?.pieces?.length || 0} piezas
|
||||||
{keepIds.size ? ` · ${keepIds.size} ✓` : ''}
|
{regenIds.size ? ` · ${regenIds.size} ✓` : ''}
|
||||||
{source ? ` · ${source}` : ''}
|
{source ? ` · ${source}` : ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 ? (
|
{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">
|
<p className="font-bold uppercase tracking-wider text-violet-700 mb-1">
|
||||||
Criterio del preview
|
Criterio del preview
|
||||||
</p>
|
</p>
|
||||||
@@ -557,10 +726,10 @@ export default function CalendarCreator({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Marca ✓ las piezas o días que te gustan. Luego:{' '}
|
Marca ✓ las piezas/días a <strong>regenerar</strong>. Luego{' '}
|
||||||
<strong>Regenerar no marcadas</strong> (v2 solo de las
|
<strong>Regenerar marcadas</strong> (con instrucciones) o{' '}
|
||||||
malas) o <strong>Solo quedarme con ✓</strong> (borra el
|
<strong>Eliminar marcadas</strong>. El resto se conserva.
|
||||||
resto). También puedes editar/borrar día a día.
|
No se guardan versiones intermedias hasta «Guardar mes».
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -593,7 +762,7 @@ export default function CalendarCreator({
|
|||||||
const key = `${month}-${String(day).padStart(2, '0')}`;
|
const key = `${month}-${String(day).padStart(2, '0')}`;
|
||||||
const hits = byDate.get(key) || [];
|
const hits = byDate.get(key) || [];
|
||||||
const dayKept =
|
const dayKept =
|
||||||
hits.length > 0 && hits.every((p) => keepIds.has(p.id));
|
hits.length > 0 && hits.every((p) => regenIds.has(p.id));
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
@@ -615,13 +784,13 @@ export default function CalendarCreator({
|
|||||||
{hits.length > 0 ? (
|
{hits.length > 0 ? (
|
||||||
<label
|
<label
|
||||||
className="flex items-center gap-0.5 text-[9px] text-emerald-700 cursor-pointer"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={dayKept}
|
checked={dayKept}
|
||||||
onChange={() => toggleKeepDay(key)}
|
onChange={() => toggleRegenDay(key)}
|
||||||
className="h-3 w-3"
|
className="h-3 w-3"
|
||||||
/>
|
/>
|
||||||
día
|
día
|
||||||
@@ -640,14 +809,14 @@ export default function CalendarCreator({
|
|||||||
<span
|
<span
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={`truncate rounded px-1 py-0.5 text-[9px] font-semibold text-white ${
|
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'
|
? 'outline outline-1 outline-offset-[-1px] outline-white'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
style={{ background: colorFor(p) }}
|
style={{ background: colorFor(p) }}
|
||||||
title={p.title}
|
title={p.title}
|
||||||
>
|
>
|
||||||
{keepIds.has(p.id) ? '✓ ' : ''}
|
{regenIds.has(p.id) ? '✓ ' : ''}
|
||||||
{p.title}
|
{p.title}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@@ -712,7 +881,7 @@ export default function CalendarCreator({
|
|||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
className={`rounded-xl border p-3 space-y-2 ${
|
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-emerald-300 bg-emerald-50/40'
|
||||||
: 'border-gray-200'
|
: '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">
|
<label className="flex items-center gap-2 text-sm font-bold text-gray-900 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={keepIds.has(p.id)}
|
checked={regenIds.has(p.id)}
|
||||||
onChange={() => toggleKeep(p.id)}
|
onChange={() => toggleRegen(p.id)}
|
||||||
/>
|
/>
|
||||||
{p.title}
|
{p.title}
|
||||||
</label>
|
</label>
|
||||||
@@ -740,6 +909,39 @@ export default function CalendarCreator({
|
|||||||
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap">
|
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap">
|
||||||
{p.body || p.hook}
|
{p.body || p.hook}
|
||||||
</p>
|
</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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -522,16 +522,25 @@ export default function CreativePipelineBoard() {
|
|||||||
payload: {
|
payload: {
|
||||||
scheduled_at: string | null;
|
scheduled_at: string | null;
|
||||||
channels: PublishChannel[];
|
channels: PublishChannel[];
|
||||||
|
channel_schedules?: { instagram?: string; facebook?: string };
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
if (!isAgency) return;
|
if (!isAgency) return;
|
||||||
setBusyId(id);
|
setBusyId(id);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
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', {
|
const res = await fetch('/api/creative-pipeline', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ id, ...payload }),
|
body: JSON.stringify({ id, ...rest, notes: notes || null }),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -1671,6 +1680,7 @@ function DetailModal({
|
|||||||
onSaveSchedule: (payload: {
|
onSaveSchedule: (payload: {
|
||||||
scheduled_at: string | null;
|
scheduled_at: string | null;
|
||||||
channels: PublishChannel[];
|
channels: PublishChannel[];
|
||||||
|
channel_schedules?: { instagram?: string; facebook?: string };
|
||||||
}) => void;
|
}) => void;
|
||||||
onSaveContent: (payload: {
|
onSaveContent: (payload: {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -1690,6 +1700,13 @@ function DetailModal({
|
|||||||
const [schedLocal, setSchedLocal] = useState(
|
const [schedLocal, setSchedLocal] = useState(
|
||||||
toDatetimeLocalValue(item.scheduled_at)
|
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(
|
const [chIg, setChIg] = useState(
|
||||||
(item.channels || ['instagram', 'facebook']).includes('instagram')
|
(item.channels || ['instagram', 'facebook']).includes('instagram')
|
||||||
);
|
);
|
||||||
@@ -1710,7 +1727,11 @@ function DetailModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
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'];
|
const ch = item.channels || ['instagram', 'facebook'];
|
||||||
setChIg(ch.includes('instagram'));
|
setChIg(ch.includes('instagram'));
|
||||||
setChFb(ch.includes('facebook'));
|
setChFb(ch.includes('facebook'));
|
||||||
@@ -1743,9 +1764,33 @@ function DetailModal({
|
|||||||
const channels: PublishChannel[] = [];
|
const channels: PublishChannel[] = [];
|
||||||
if (chIg) channels.push('instagram');
|
if (chIg) channels.push('instagram');
|
||||||
if (chFb) channels.push('facebook');
|
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({
|
onSaveSchedule({
|
||||||
scheduled_at: schedLocal ? new Date(schedLocal).toISOString() : null,
|
scheduled_at: primary,
|
||||||
channels: channels.length ? channels : ['instagram', 'facebook'],
|
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">
|
<p className="text-xs font-bold uppercase tracking-wider text-sky-900">
|
||||||
Programación (solo agencia)
|
Programación (solo agencia)
|
||||||
</p>
|
</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">
|
<div className="flex flex-wrap gap-3 text-xs text-gray-800">
|
||||||
<label className="inline-flex items-center gap-1.5">
|
<label className="inline-flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
@@ -2027,6 +2063,66 @@ function DetailModal({
|
|||||||
Facebook
|
Facebook
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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' ? (
|
{item.publish_status && item.publish_status !== 'idle' ? (
|
||||||
<p className="text-[11px] text-gray-600">
|
<p className="text-[11px] text-gray-600">
|
||||||
Worker: <strong>{item.publish_status}</strong>
|
Worker: <strong>{item.publish_status}</strong>
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ import {
|
|||||||
type PipelineCalendarRow,
|
type PipelineCalendarRow,
|
||||||
} from '@/lib/pipeline-calendar';
|
} from '@/lib/pipeline-calendar';
|
||||||
|
|
||||||
|
/** Texto concreto por slide (carrusel / story multi). */
|
||||||
|
export type RrssSlideCopy = {
|
||||||
|
n: number;
|
||||||
|
label?: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type RrssPiece = {
|
export type RrssPiece = {
|
||||||
id: string;
|
id: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -38,7 +45,17 @@ export type RrssPiece = {
|
|||||||
meta_line?: string;
|
meta_line?: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
/** @deprecated prefer slide_copy */
|
||||||
slides?: string[];
|
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;
|
pipeline_id?: string;
|
||||||
cover_path?: string | null;
|
cover_path?: string | null;
|
||||||
slide_paths?: string[];
|
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,
|
monthTrendBeats,
|
||||||
strategistSystemPrompt,
|
strategistSystemPrompt,
|
||||||
strategyMonthPreview,
|
strategyMonthPreview,
|
||||||
|
type BuildSlotsOpts,
|
||||||
type PublishedNetworkPost,
|
type PublishedNetworkPost,
|
||||||
} from '@/lib/calendar-strategy';
|
} from '@/lib/calendar-strategy';
|
||||||
import type { Brandbook } from '@/lib/brandbook';
|
import type { Brandbook } from '@/lib/brandbook';
|
||||||
|
import type { CalendarGenOptions } from '@/lib/calendar-gen-options';
|
||||||
|
import { normalizeGenOptions } from '@/lib/calendar-gen-options';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
brandBrief,
|
brandBrief,
|
||||||
@@ -37,6 +40,8 @@ export type GeneratePreviewInput = {
|
|||||||
publishedPosts?: PublishedNetworkPost[];
|
publishedPosts?: PublishedNetworkPost[];
|
||||||
/** Títulos / hooks a no repetir (piezas que el usuario conserva). */
|
/** Títulos / hooks a no repetir (piezas que el usuario conserva). */
|
||||||
avoidTitles?: string[];
|
avoidTitles?: string[];
|
||||||
|
/** Cantidades, días, mix e instrucciones del operador. */
|
||||||
|
genOptions?: Partial<CalendarGenOptions> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeneratePreviewResult = {
|
export type GeneratePreviewResult = {
|
||||||
@@ -84,6 +89,7 @@ async function anthropicStrategist(
|
|||||||
input.brandbook,
|
input.brandbook,
|
||||||
input.publishedPosts || []
|
input.publishedPosts || []
|
||||||
);
|
);
|
||||||
|
const go = normalizeGenOptions(input.genOptions);
|
||||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
||||||
const prompt = strategistSystemPrompt({
|
const prompt = strategistSystemPrompt({
|
||||||
ym: input.ym,
|
ym: input.ym,
|
||||||
@@ -91,14 +97,22 @@ async function anthropicStrategist(
|
|||||||
dna,
|
dna,
|
||||||
trends,
|
trends,
|
||||||
avoidTitles: input.avoidTitles || [],
|
avoidTitles: input.avoidTitles || [],
|
||||||
|
genOptions: go,
|
||||||
});
|
});
|
||||||
|
const minPieces = Math.max(
|
||||||
|
3,
|
||||||
|
Math.min(
|
||||||
|
8,
|
||||||
|
go.posts + go.stories + go.reels + go.carruseles
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
||||||
const client = new Anthropic({ apiKey: key });
|
const client = new Anthropic({ apiKey: key });
|
||||||
const msg = await client.messages.create({
|
const msg = await client.messages.create({
|
||||||
model: 'claude-sonnet-4-20250514',
|
model: 'claude-sonnet-4-20250514',
|
||||||
max_tokens: 4500,
|
max_tokens: 8000,
|
||||||
messages: [{ role: 'user', content: prompt }],
|
messages: [{ role: 'user', content: prompt }],
|
||||||
});
|
});
|
||||||
const text = msg.content
|
const text = msg.content
|
||||||
@@ -110,7 +124,7 @@ async function anthropicStrategist(
|
|||||||
rationale?: string;
|
rationale?: string;
|
||||||
pieces?: Array<Partial<RrssPiece> & { objective_metric?: 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;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,6 +139,15 @@ async function anthropicStrategist(
|
|||||||
const format = String(p.format || 'Post');
|
const format = String(p.format || 'Post');
|
||||||
const kind = kindOf(format, p.kind);
|
const kind = kindOf(format, p.kind);
|
||||||
const platform = String(p.platform || 'instagram').toLowerCase();
|
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 {
|
return {
|
||||||
id: newPieceId(date, title),
|
id: newPieceId(date, title),
|
||||||
date,
|
date,
|
||||||
@@ -142,11 +165,18 @@ async function anthropicStrategist(
|
|||||||
status: 'planned',
|
status: 'planned',
|
||||||
kind,
|
kind,
|
||||||
color: colorFor(format, kind, platform),
|
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;
|
} as RrssPiece;
|
||||||
})
|
})
|
||||||
.filter(Boolean) 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);
|
const plan = emptyMonthPlan(input.ym, input.label);
|
||||||
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`;
|
plan.hero.lede = `Estrategia IA · ${monthLabel(input.ym)} · engagement 2026 + ADN ${input.label}.`;
|
||||||
plan.hero.chips = [
|
plan.hero.chips = [
|
||||||
@@ -177,11 +207,27 @@ export async function generateMonthPreview(
|
|||||||
}))
|
}))
|
||||||
.filter((p) => (p.pieces?.length || 0) >= 3);
|
.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({
|
const strategist = strategyMonthPreview({
|
||||||
...input,
|
...input,
|
||||||
history: cleanHistory,
|
history: cleanHistory,
|
||||||
brandbook: input.brandbook,
|
brandbook: input.brandbook,
|
||||||
publishedPosts: input.publishedPosts || [],
|
publishedPosts: input.publishedPosts || [],
|
||||||
|
genOptions: slotOpts,
|
||||||
});
|
});
|
||||||
const fallback: GeneratePreviewResult = {
|
const fallback: GeneratePreviewResult = {
|
||||||
plan: strategist.plan,
|
plan: strategist.plan,
|
||||||
@@ -194,6 +240,7 @@ export async function generateMonthPreview(
|
|||||||
...input,
|
...input,
|
||||||
history: cleanHistory,
|
history: cleanHistory,
|
||||||
publishedPosts: input.publishedPosts || [],
|
publishedPosts: input.publishedPosts || [],
|
||||||
|
genOptions: go,
|
||||||
},
|
},
|
||||||
fallback
|
fallback
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
brandbookToPrompt,
|
brandbookToPrompt,
|
||||||
type Brandbook,
|
type Brandbook,
|
||||||
} from '@/lib/brandbook';
|
} from '@/lib/brandbook';
|
||||||
|
import { resolveBrandSlug } from '@/lib/tenant-brand-aliases';
|
||||||
|
|
||||||
/** Publicación real en redes (pipeline / IG sync) usada como señal creativa. */
|
/** Publicación real en redes (pipeline / IG sync) usada como señal creativa. */
|
||||||
export type PublishedNetworkPost = {
|
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 [y, m] = ym.split('-').map(Number);
|
||||||
const days: string[] = [];
|
const days: string[] = [];
|
||||||
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||||
for (let d = 1; d <= dim; d++) {
|
for (let d = 1; d <= dim; d++) {
|
||||||
const dt = new Date(Date.UTC(y, m - 1, 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')}`);
|
days.push(`${ym}-${String(d).padStart(2, '0')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -406,17 +411,40 @@ export function extractClientDna(
|
|||||||
'Certificaciones',
|
'Certificaciones',
|
||||||
'Matriculación',
|
'Matriculación',
|
||||||
],
|
],
|
||||||
'talento-y-deporte': [
|
tyd: [
|
||||||
'Nuevo alumno',
|
'Nuevo alumno',
|
||||||
'Vocab / expresiones',
|
'Beca deportiva',
|
||||||
'Campus',
|
'Campus USA',
|
||||||
'Errores / Fix it',
|
'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
|
const fromBook = brandbook?.series_recurrentes?.length
|
||||||
? brandbook.series_recurrentes
|
? brandbook.series_recurrentes
|
||||||
: brandbook?.pillars_rrss || [];
|
: brandbook?.pillars_rrss || [];
|
||||||
const seed = fromBook.length ? fromBook : seedBySlug[slug] || [];
|
const seed = fromBook.length
|
||||||
|
? fromBook
|
||||||
|
: seedBySlug[resolveBrandSlug(slug)] || seedBySlug[slug] || [];
|
||||||
const signatureSeries = [
|
const signatureSeries = [
|
||||||
...seed,
|
...seed,
|
||||||
...inferred.filter((s) => !seed.includes(s)),
|
...inferred.filter((s) => !seed.includes(s)),
|
||||||
@@ -455,22 +483,64 @@ type Slot = {
|
|||||||
role: 'reach' | 'save' | 'chat' | 'convert' | 'authority';
|
role: 'reach' | 'save' | 'chat' | 'convert' | 'authority';
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildMonthSlots(ym: string, dna: ClientDna): Slot[] {
|
export type BuildSlotsOpts = {
|
||||||
const weekdays = weekdaysInMonth(ym);
|
posts?: number;
|
||||||
const target = Math.min(
|
stories?: number;
|
||||||
|
reels?: number;
|
||||||
|
carruseles?: number;
|
||||||
|
weekdays?: number[];
|
||||||
|
/** Si true, se pueden apilar story+post / reel+story el mismo día. */
|
||||||
|
allowSameDayMix?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
weekdays.length,
|
||||||
Math.max(16, Math.round(dna.avgPerWeek * 4 * 0.85)) // quality > quantity (−15% vs legacy)
|
Math.max(16, Math.round(dna.avgPerWeek * 4 * 0.85))
|
||||||
);
|
);
|
||||||
const mix = PLAYBOOK_2026.mix;
|
const mix = PLAYBOOK_2026.mix;
|
||||||
const counts = {
|
counts = {
|
||||||
Reel: Math.round(target * mix.reel),
|
Reel: Math.round(target * mix.reel),
|
||||||
Carrusel: Math.round(target * mix.carrusel),
|
Carrusel: Math.round(target * mix.carrusel),
|
||||||
Stories: Math.round(target * mix.stories),
|
Stories: Math.round(target * mix.stories),
|
||||||
Post: 0,
|
Post: 0,
|
||||||
};
|
};
|
||||||
counts.Post = Math.max(0, target - counts.Reel - counts.Carrusel - counts.Stories);
|
counts.Post = Math.max(
|
||||||
|
0,
|
||||||
|
target - counts.Reel - counts.Carrusel - counts.Stories
|
||||||
|
);
|
||||||
|
target =
|
||||||
|
counts.Reel + counts.Carrusel + counts.Stories + counts.Post;
|
||||||
|
}
|
||||||
|
|
||||||
// Round-robin interleave so the month isn't "all Reels then all Stories"
|
// Round-robin interleave
|
||||||
const bags: Record<Slot['format'], number> = { ...counts };
|
const bags: Record<Slot['format'], number> = { ...counts };
|
||||||
const formats: Slot['format'][] = ['Reel', 'Carrusel', 'Stories', 'Post'];
|
const formats: Slot['format'][] = ['Reel', 'Carrusel', 'Stories', 'Post'];
|
||||||
const queue: Array<Slot['format']> = [];
|
const queue: Array<Slot['format']> = [];
|
||||||
@@ -487,20 +557,53 @@ function buildMonthSlots(ym: string, dna: ClientDna): Slot[] {
|
|||||||
if (!added) break;
|
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 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;
|
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++) {
|
for (let qi = 0; qi < queue.length; qi++) {
|
||||||
const format = queue[qi];
|
const format = queue[qi];
|
||||||
const date = weekdays[Math.min(dayIdx, weekdays.length - 1)];
|
let d =
|
||||||
dayIdx += step;
|
weekdays[Math.min(dayIdx, weekdays.length - 1)] || weekdays[0];
|
||||||
if (dayIdx >= weekdays.length) dayIdx = qi % weekdays.length;
|
dayIdx = (dayIdx + step) % weekdays.length;
|
||||||
// Prefer unused weekdays
|
|
||||||
let d = date;
|
if (!dayAccepts(d, format)) {
|
||||||
if (slots.some((s) => s.date === d)) {
|
const free = weekdays.find((w) => dayAccepts(w, format));
|
||||||
d = weekdays.find((w) => !slots.some((s) => s.date === w)) || date;
|
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 =
|
const isConvert =
|
||||||
qi > 0 &&
|
qi > 0 &&
|
||||||
qi % convertEvery === 0 &&
|
qi % convertEvery === 0 &&
|
||||||
@@ -653,6 +756,17 @@ function pickAngleTitle(
|
|||||||
return `${prefix} · ${core}`.slice(0, 140);
|
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. */
|
/** Creative angle factory — NEW titles, DNA-aligned. */
|
||||||
function inventAngle(
|
function inventAngle(
|
||||||
dna: ClientDna,
|
dna: ClientDna,
|
||||||
@@ -661,7 +775,7 @@ function inventAngle(
|
|||||||
index: number,
|
index: number,
|
||||||
/** Extra offset only for title bank rotation (dedupe). */
|
/** Extra offset only for title bank rotation (dedupe). */
|
||||||
bankOffset = 0
|
bankOffset = 0
|
||||||
): { title: string; hook: string; body: string; cta: string; pillar: string } {
|
): AngleOut {
|
||||||
const series =
|
const series =
|
||||||
dna.signatureSeries[index % Math.max(1, dna.signatureSeries.length)] ||
|
dna.signatureSeries[index % Math.max(1, dna.signatureSeries.length)] ||
|
||||||
'Educativo';
|
'Educativo';
|
||||||
@@ -682,15 +796,20 @@ function inventAngle(
|
|||||||
? title
|
? title
|
||||||
: `STORIES · ${pillar}: encuesta “¿este mes o enero?”`,
|
: `STORIES · ${pillar}: encuesta “¿este mes o enero?”`,
|
||||||
hook: 'Una pregunta. Tu respuesta abre el DM.',
|
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',
|
cta: 'Responde y te orientamos',
|
||||||
pillar: 'conversion',
|
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 {
|
return {
|
||||||
title,
|
title,
|
||||||
hook: 'La diferencia entre “lo pienso” y “ya estoy dentro”.',
|
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',
|
cta: 'Link en bio / test de nivel',
|
||||||
pillar: 'conversion',
|
pillar: 'conversion',
|
||||||
};
|
};
|
||||||
@@ -699,6 +818,14 @@ function inventAngle(
|
|||||||
const title = pickAngleTitle(series, beat, bankIndex, slot.format);
|
const title = pickAngleTitle(series, beat, bankIndex, slot.format);
|
||||||
|
|
||||||
if (slot.format === 'Reel') {
|
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 {
|
return {
|
||||||
title,
|
title,
|
||||||
hook: `${hookStyle}`,
|
hook: `${hookStyle}`,
|
||||||
@@ -706,38 +833,54 @@ function inventAngle(
|
|||||||
`Objetivo: ALCANCE (Reels 2026 ES).`,
|
`Objetivo: ALCANCE (Reels 2026 ES).`,
|
||||||
`ADN: ${series}. Tendencia: ${beat.label} — ${beat.why}`,
|
`ADN: ${series}. Tendencia: ${beat.label} — ${beat.why}`,
|
||||||
`Estructura: hook 3s → micro-lección → CTA share/save.`,
|
`Estructura: hook 3s → micro-lección → CTA share/save.`,
|
||||||
`Texto en pantalla + caption con keyword buscable.`,
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
cta: 'Comparte con alguien que lo necesite',
|
cta: 'Comparte con alguien que lo necesite',
|
||||||
pillar: 'reach',
|
pillar: 'reach',
|
||||||
|
reel_script,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slot.format === 'Carrusel') {
|
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 {
|
return {
|
||||||
title: title.replace(/^REEL ·/, 'CARRUSEL ·').replace(/^POST ·/, 'CARRUSEL ·'),
|
title: title.replace(/^REEL ·/, 'CARRUSEL ·').replace(/^POST ·/, 'CARRUSEL ·'),
|
||||||
hook: 'Guarda esto antes de que se te olvide.',
|
hook: 'Guarda esto antes de que se te olvide.',
|
||||||
body: [
|
body: [
|
||||||
`Objetivo: GUARDADOS + shares.`,
|
`Objetivo: GUARDADOS + shares.`,
|
||||||
`ADN: ${series}. ${beat.why}`,
|
`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'),
|
].join('\n'),
|
||||||
cta: 'Guarda + envía a tu compañero de estudio',
|
cta: 'Guarda + envía a tu compañero de estudio',
|
||||||
pillar: 'saves',
|
pillar: 'saves',
|
||||||
|
slide_copy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slot.format === 'Stories') {
|
if (slot.format === 'Stories') {
|
||||||
|
const interactive =
|
||||||
|
index % 3 === 0 ? 'encuesta' : index % 3 === 1 ? 'quiz' : 'caja';
|
||||||
return {
|
return {
|
||||||
title: title.replace(/^(REEL|CARRUSEL|POST) ·/, 'STORIES ·'),
|
title: title.replace(/^(REEL|CARRUSEL|POST) ·/, 'STORIES ·'),
|
||||||
hook: 'Tú eliges: A o B',
|
hook: 'Tú eliges: A o B',
|
||||||
body: [
|
body: [
|
||||||
`Objetivo: REPLIES (+88% conversación Stories 2026).`,
|
`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,
|
beat.label,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
cta: 'Responde a la story',
|
cta: 'Responde a la story',
|
||||||
pillar: 'stories',
|
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 ·'),
|
title: title.replace(/^(REEL|CARRUSEL|STORIES) ·/, 'POST ·'),
|
||||||
hook: `Lo que nadie te explica de ${series.toLowerCase()}.`,
|
hook: `Lo que nadie te explica de ${series.toLowerCase()}.`,
|
||||||
body: [
|
body: [
|
||||||
`Feed autoridad + SEO caption (“cómo se dice…”, “diferencia entre…”).`,
|
`Feed autoridad + SEO caption.`,
|
||||||
`ADN ${brand}: ${series}.`,
|
`ADN ${brand}: ${series}.`,
|
||||||
`Tendencia: ${beat.why}`,
|
`Tendencia: ${beat.why}`,
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
@@ -769,6 +912,7 @@ export function strategyMonthPreview(input: {
|
|||||||
history: RrssMonthPlan[];
|
history: RrssMonthPlan[];
|
||||||
brandbook?: Brandbook | null;
|
brandbook?: Brandbook | null;
|
||||||
publishedPosts?: PublishedNetworkPost[];
|
publishedPosts?: PublishedNetworkPost[];
|
||||||
|
genOptions?: BuildSlotsOpts & { instructions?: string };
|
||||||
}): StrategyPreviewResult {
|
}): StrategyPreviewResult {
|
||||||
const dna = extractClientDna(
|
const dna = extractClientDna(
|
||||||
input.slug,
|
input.slug,
|
||||||
@@ -778,7 +922,7 @@ export function strategyMonthPreview(input: {
|
|||||||
input.publishedPosts || []
|
input.publishedPosts || []
|
||||||
);
|
);
|
||||||
const trends = monthTrendBeats(input.ym, dna.brand.vertical);
|
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 pieces: RrssPiece[] = [];
|
||||||
|
|
||||||
const usedCores = new Set<string>();
|
const usedCores = new Set<string>();
|
||||||
@@ -824,6 +968,9 @@ export function strategyMonthPreview(input: {
|
|||||||
kind,
|
kind,
|
||||||
color: colorFor(slot.format, kind),
|
color: colorFor(slot.format, kind),
|
||||||
objective_metric: useBeat.kpi,
|
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 = [
|
plan.hero.chips = [
|
||||||
`${pieces.length} piezas`,
|
`${pieces.length} piezas`,
|
||||||
`${pieces.filter((p) => p.format === 'Reel').length} Reels`,
|
`${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',
|
dna.networkPostsUsed ? `${dna.networkPostsUsed} pubs hist.` : 'strategist',
|
||||||
];
|
];
|
||||||
plan.pieces = pieces;
|
plan.pieces = pieces;
|
||||||
|
|
||||||
|
const go = input.genOptions;
|
||||||
const rationale = [
|
const rationale = [
|
||||||
`Estrategia (no copia): DNA de ${dna.brand.label} = series ${dna.signatureSeries.slice(0, 5).join(', ') || 'vertical'}.`,
|
`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'}.`,
|
`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
|
`Tendencias ${monthLabel(input.ym)}: ${trends
|
||||||
.slice(0, 4)
|
.slice(0, 4)
|
||||||
.map((t) => t.label)
|
.map((t) => t.label)
|
||||||
.join(' · ')}.`,
|
.join(' · ')}.`,
|
||||||
`Horas: Reels ${PLAYBOOK_2026.hours.reel} · Carrusel ${PLAYBOOK_2026.hours.carrusel} · Stories ${PLAYBOOK_2026.hours.stories} · Post ${PLAYBOOK_2026.hours.post}.`,
|
`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.`,
|
`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
|
dna.brandbookPrompt
|
||||||
? 'Brandbook activo como constraint creativo.'
|
? 'Brandbook activo como constraint creativo.'
|
||||||
: dna.voiceNotes,
|
: dna.voiceNotes,
|
||||||
].join(' ');
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
return { plan, rationale, source: 'strategy', trends, dna };
|
return { plan, rationale, source: 'strategy', trends, dna };
|
||||||
}
|
}
|
||||||
@@ -861,6 +1017,10 @@ export function strategistSystemPrompt(input: {
|
|||||||
dna: ClientDna;
|
dna: ClientDna;
|
||||||
trends: TrendBeat[];
|
trends: TrendBeat[];
|
||||||
avoidTitles?: string[];
|
avoidTitles?: string[];
|
||||||
|
genOptions?: BuildSlotsOpts & {
|
||||||
|
instructions?: string;
|
||||||
|
regenInstructions?: string;
|
||||||
|
};
|
||||||
}): string {
|
}): string {
|
||||||
const netBlock =
|
const netBlock =
|
||||||
input.dna.networkSamples.length > 0
|
input.dna.networkSamples.length > 0
|
||||||
@@ -875,6 +1035,16 @@ export function strategistSystemPrompt(input: {
|
|||||||
.map((t) => `- ${t}`)
|
.map((t) => `- ${t}`)
|
||||||
.join('\n')}\n`
|
.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}).
|
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:
|
NO copies títulos viejos ni captiones publicadas. Usa como constraint creativo:
|
||||||
1) Zona de marca / brandbook
|
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}.
|
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)}%.
|
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` : ''}
|
${input.dna.brandbookPrompt ? `\nBRANDBOOK:\n${input.dna.brandbookPrompt}\n` : ''}
|
||||||
${netBlock}${avoidBlock}
|
${netBlock}${avoidBlock}${instrBlock}
|
||||||
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
|
Playbook 2026 ES: ${PLAYBOOK_2026.principle}
|
||||||
|
${mixBlock}
|
||||||
Tendencias del mes:
|
Tendencias del mes:
|
||||||
${input.trends.map((t) => `- ${t.label}: ${t.why}`).join('\n')}
|
${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}] }
|
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; 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.`;
|
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
|
// Publish IG then FB in two steps, persisting each Meta id immediately
|
||||||
// so a crash / FB failure cannot create a duplicate IG on retry.
|
// so a crash / FB failure cannot create a duplicate IG on retry.
|
||||||
const channels = normalizeChannels(item.channels);
|
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[] = [];
|
const errors: string[] = [];
|
||||||
let igMediaId: string | null = item.meta_ig_media_id || null;
|
let igMediaId: string | null = item.meta_ig_media_id || null;
|
||||||
let fbPostId: string | null = item.meta_fb_post_id || null;
|
let fbPostId: string | null = item.meta_fb_post_id || null;
|
||||||
let igPermalink: string | null = null;
|
let igPermalink: string | null = null;
|
||||||
let fbPermalink: 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 });
|
const ig = await publishPipelineItem(item, { skipFb: true });
|
||||||
if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) {
|
if (ig.sanitizedCaption && ig.sanitizedCaption !== (item.caption || '')) {
|
||||||
await patchItem(base, headers, item.id, {
|
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(
|
const fb = await publishPipelineItem(
|
||||||
{ ...item, meta_ig_media_id: igMediaId },
|
{ ...item, meta_ig_media_id: igMediaId },
|
||||||
{ skipIg: true }
|
{ skipIg: true }
|
||||||
@@ -235,8 +259,28 @@ export async function processDuePipelinePublishes(opts?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantIg = channels.includes('instagram');
|
const wantIg = channels.includes('instagram') && channelDue('instagram');
|
||||||
const wantFb = channels.includes('facebook');
|
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 igOk = !wantIg || Boolean(igMediaId);
|
||||||
const fbOk = !wantFb || Boolean(fbPostId);
|
const fbOk = !wantFb || Boolean(fbPostId);
|
||||||
const ok = igOk && fbOk && errors.length === 0;
|
const ok = igOk && fbOk && errors.length === 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user