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

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

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

View File

@@ -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"