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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user