Add in-app editorial calendar creator with month zoom and preview.

Agency can pick a month, generate a brand/seasonality preview, and add/edit/delete pieces with the same fields as the creative pipeline, optionally syncing drafts there.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-08 18:12:10 +02:00
parent 501f8203e8
commit f316497650
7 changed files with 1700 additions and 8 deletions

View File

@@ -0,0 +1,404 @@
/**
* Create / update editorial month plans and pieces.
* POST { action: 'create'|'upsert_piece'|'delete_piece'|'replace_plan', month, piece?, plan?, syncPipeline? }
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant } from '@/lib/tenant-context';
import {
isAgencyRole,
serviceHeaders,
supabaseUrl,
userRole,
} from '@/lib/agency';
import {
asPlanObject,
emptyMonthPlan,
newPieceId,
buildCaption,
mapFormatToPipeline,
scheduledAtFromPiece,
type CalendarDoc,
} from '@/lib/calendar-documents';
import { monthLabel } from '@/lib/calendar-import';
import { sanitizePublishCaption } from '@/lib/publish-caption';
import { enrichPieceFromCatalog } from '@/lib/calendar-template';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
export const dynamic = 'force-dynamic';
function str(v: unknown, max = 4000): string {
return typeof v === 'string' ? v.trim().slice(0, max) : '';
}
async function loadMonthDoc(
base: string,
headers: Record<string, string>,
tenantId: string,
month: string
): Promise<{ doc: CalendarDoc | null; plan: RrssMonthPlan | null }> {
const r = await fetch(
`${base}/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
tenantId
)}&type=eq.calendar&select=id,title,content`,
{ headers, cache: 'no-store' }
);
if (!r.ok) return { doc: null, plan: null };
const docs = (await r.json()) as CalendarDoc[];
for (const d of docs) {
const plan = asPlanObject(d.content) as RrssMonthPlan | null;
if (plan && plan.month === month) {
return { doc: d, plan: plan as unknown as RrssMonthPlan };
}
}
return { doc: null, plan: null };
}
async function savePlan(
base: string,
headers: Record<string, string>,
tenantId: string,
plan: RrssMonthPlan,
existingId?: string | null
) {
const title = `RRSS ${plan.label || monthLabel(plan.month)}`;
if (existingId) {
const patch = await fetch(
`${base}/rest/v1/documents?id=eq.${encodeURIComponent(existingId)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
title,
content: plan,
status: 'approved',
updated_at: new Date().toISOString(),
}),
}
);
if (!patch.ok) throw new Error(await patch.text());
const rows = await patch.json();
return Array.isArray(rows) ? rows[0] : rows;
}
const ins = await fetch(`${base}/rest/v1/documents`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
tenant_id: tenantId,
type: 'calendar',
title,
content: plan,
status: 'approved',
}),
});
if (!ins.ok) throw new Error(await ins.text());
const rows = await ins.json();
return Array.isArray(rows) ? rows[0] : rows;
}
function normalizePiece(
raw: Record<string, unknown>,
fallbackDate: string
): RrssPiece {
const date = str(raw.date, 10) || fallbackDate;
const title = str(raw.title, 160) || 'Sin título';
const format = str(raw.format, 40) || 'Post';
const kindRaw = str(raw.kind, 20);
const kind =
kindRaw ||
(/stor/i.test(format) ? 'story' : /reel/i.test(format) ? 'reel' : 'post');
const cleanBody = sanitizePublishCaption(
str(raw.body, 4000) || title,
{ maxLen: 4000 }
).caption;
const enriched = enrichPieceFromCatalog({
title,
platform: str(raw.platform, 40) || 'instagram',
format,
pillar: str(raw.pillar, 80),
time: str(raw.time, 5),
cta: str(raw.cta, 200),
hashtags: str(raw.hashtags, 500),
hook: str(raw.hook, 300),
body: cleanBody,
});
return {
id: str(raw.id, 120) || newPieceId(date, title),
date,
time: enriched.time || '10:30',
platform: (enriched.platform || 'instagram').toLowerCase(),
format: enriched.format || format,
pillar: enriched.pillar || undefined,
title: enriched.title,
hook: enriched.hook || enriched.title,
body: enriched.body || enriched.title,
cta: enriched.cta || undefined,
hashtags: enriched.hashtags || undefined,
objective_metric: str(raw.objective_metric || raw.objetivo, 200) || undefined,
status: str(raw.status, 40) || 'planned',
kind,
color:
kind === 'story'
? '#374151'
: /linkedin/i.test(enriched.platform || '')
? '#7C66B8'
: '#2071cf',
};
}
async function syncPieceToPipeline(
base: string,
headers: Record<string, string>,
tenantId: string,
userId: string,
piece: RrssPiece,
month: string
) {
const caption = sanitizePublishCaption(
buildCaption(piece) || piece.title,
{ maxLen: 4000 }
).caption;
// Find existing by calendar_piece_id
const find = await fetch(
`${base}/rest/v1/creative_pipeline?tenant_id=eq.${encodeURIComponent(
tenantId
)}&calendar_piece_id=eq.${encodeURIComponent(piece.id)}&select=id&limit=1`,
{ headers, cache: 'no-store' }
);
const existing = find.ok ? ((await find.json()) as { id: string }[]) : [];
const row: Record<string, unknown> = {
title: piece.title,
format: mapFormatToPipeline(piece.format, piece.kind),
month,
caption: caption || null,
notes: piece.pillar ? `Pilar: ${piece.pillar}` : null,
calendar_piece_id: piece.id,
scheduled_at: scheduledAtFromPiece(piece),
channels:
(piece.platform || '').toLowerCase() === 'facebook'
? ['facebook', 'instagram']
: ['instagram', 'facebook'],
updated_at: new Date().toISOString(),
};
if (existing[0]?.id) {
await fetch(
`${base}/rest/v1/creative_pipeline?id=eq.${encodeURIComponent(existing[0].id)}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=minimal',
},
body: JSON.stringify(row),
}
);
return existing[0].id;
}
const ins = await fetch(`${base}/rest/v1/creative_pipeline`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
...row,
tenant_id: tenantId,
status: 'propuesta',
publish_status: 'idle',
created_by: userId,
}),
});
if (!ins.ok) return null;
const rows = (await ins.json()) as { id: string }[];
return rows[0]?.id || null;
}
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const action = str(body.action, 40) || 'create';
const month = str(body.month, 7);
if (!/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 });
}
const { doc, plan: existing } = await loadMonthDoc(
base,
headers,
ctx.tenantId,
month
);
try {
if (action === 'create') {
if (existing && !(body.force === true)) {
return NextResponse.json({
ok: true,
existed: true,
plan: existing,
document_id: doc?.id,
});
}
const seed = body.plan as RrssMonthPlan | undefined;
const plan =
seed && seed.month === month
? seed
: emptyMonthPlan(month, ctx.label);
const saved = await savePlan(
base,
headers,
ctx.tenantId,
plan,
body.force === true ? doc?.id : null
);
return NextResponse.json({
ok: true,
created: true,
plan,
document_id: saved?.id,
});
}
if (action === 'replace_plan') {
const seed = body.plan as RrssMonthPlan | undefined;
if (!seed || seed.month !== month) {
return NextResponse.json({ error: 'plan inválido' }, { status: 400 });
}
const saved = await savePlan(base, headers, ctx.tenantId, seed, doc?.id);
return NextResponse.json({
ok: true,
plan: seed,
document_id: saved?.id,
});
}
if (action === 'upsert_piece') {
const raw = (body.piece || {}) as Record<string, unknown>;
const piece = normalizePiece(raw, `${month}-01`);
if (!piece.date.startsWith(month)) {
return NextResponse.json(
{ error: 'La fecha debe pertenecer al mes' },
{ status: 400 }
);
}
let plan = existing || emptyMonthPlan(month, ctx.label);
const idx = plan.pieces.findIndex((p) => p.id === piece.id);
if (idx >= 0) plan.pieces[idx] = { ...plan.pieces[idx], ...piece };
else plan.pieces.push(piece);
plan.pieces.sort((a, b) => a.date.localeCompare(b.date));
plan.hero = {
...plan.hero,
chips: [`${plan.pieces.length} piezas`],
};
const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id);
let pipelineId: string | null = null;
if (body.syncPipeline !== false) {
pipelineId = await syncPieceToPipeline(
base,
headers,
ctx.tenantId,
user.id,
piece,
month
);
}
return NextResponse.json({
ok: true,
piece,
plan,
document_id: saved?.id,
pipeline_id: pipelineId,
});
}
if (action === 'delete_piece') {
const pieceId = str(body.pieceId || (body.piece as { id?: string })?.id, 120);
if (!pieceId || !existing) {
return NextResponse.json({ error: 'pieza no encontrada' }, { status: 404 });
}
const plan = {
...existing,
pieces: existing.pieces.filter((p) => p.id !== pieceId),
};
plan.hero = {
...plan.hero,
chips: [`${plan.pieces.length} piezas`],
};
const saved = await savePlan(base, headers, ctx.tenantId, plan, doc?.id);
return NextResponse.json({
ok: true,
plan,
document_id: saved?.id,
});
}
return NextResponse.json({ error: 'action desconocida' }, { status: 400 });
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message.slice(0, 400) : 'Error' },
{ status: 500 }
);
}
}
export async function GET(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json({ error: 'Selecciona un cliente' }, { status: 400 });
}
const headers = serviceHeaders();
const base = supabaseUrl();
if (!headers || !base) {
return NextResponse.json({ error: 'Service role required' }, { status: 503 });
}
const month = req.nextUrl.searchParams.get('month') || '';
if (!/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json({ error: 'month requerido' }, { status: 400 });
}
const { doc, plan } = await loadMonthDoc(base, headers, ctx.tenantId, month);
return NextResponse.json({
ok: true,
plan,
document_id: doc?.id || null,
exists: Boolean(plan),
});
}

View File

@@ -0,0 +1,73 @@
/**
* POST { month: 'YYYY-MM' } → editorial preview for active tenant.
*/
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { asPlanObject } from '@/lib/calendar-documents';
import { generateMonthPreview } from '@/lib/calendar-generate';
import type { RrssMonthPlan } from '@/components/RrssMonthView';
export const dynamic = 'force-dynamic';
export const maxDuration = 60;
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto.' },
{ status: 400 }
);
}
const body = (await req.json().catch(() => ({}))) as { month?: string };
const month = String(body.month || '').slice(0, 7);
if (!/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json({ error: 'month YYYY-MM requerido' }, { status: 400 });
}
let slug = 'cliente';
const t = await restGet<{ slug?: string }[]>(
`/rest/v1/tenants?id=eq.${encodeURIComponent(ctx.tenantId)}&select=slug`
);
slug = t.data?.[0]?.slug || ctx.label.toLowerCase().replace(/\s+/g, '-');
const docs = await restGet<{ content?: unknown }[]>(
`/rest/v1/documents?tenant_id=eq.${encodeURIComponent(
ctx.tenantId
)}&type=eq.calendar&select=content&order=created_at.desc&limit=24`
);
const history: RrssMonthPlan[] = [];
for (const d of docs.data || []) {
const plan = asPlanObject(d.content) as RrssMonthPlan | null;
if (plan?.month && plan.month !== month && Array.isArray(plan.pieces)) {
history.push(plan as unknown as RrssMonthPlan);
}
}
const result = await generateMonthPreview({
ym: month,
slug,
label: ctx.label,
history,
});
return NextResponse.json({
ok: true,
plan: result.plan,
rationale: result.rationale,
source: result.source,
brand: slug,
});
}

View File

@@ -11,6 +11,7 @@ import octPlan from '@/data/rrss-octubre-2026.json';
import { Instagram, Linkedin } from 'lucide-react';
import { PageHeader } from '@/components/ui';
import CalendarUpload from '@/components/CalendarUpload';
import CalendarCreator from '@/components/CalendarCreator';
import {
mergePipelineIntoPlans,
type PipelineCalendarRow,
@@ -179,7 +180,14 @@ export default async function CalendarioPage({
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
</Link>
</div>
{agency ? <CalendarUpload tenantLabel={ctx.label} /> : null}
{agency ? (
<div className="flex flex-wrap items-center gap-3">
<CalendarCreator
tenantLabel={ctx.label}
existingMonths={plans.map((p) => p.month).filter(Boolean)}
/>
</div>
) : null}
</div>
);
}
@@ -215,7 +223,14 @@ export default async function CalendarioPage({
if (active && active.kind === 'rrss_month') {
return (
<div className="space-y-5">
{agency ? <CalendarUpload tenantLabel={ctx.label} /> : null}
{agency ? (
<div className="flex flex-wrap items-center gap-3">
<CalendarCreator
tenantLabel={ctx.label}
existingMonths={plans.map((p) => p.month).filter(Boolean)}
/>
</div>
) : null}
<RrssMonthView
plan={active}
tenantLabel={ctx.label}
@@ -239,10 +254,25 @@ export default async function CalendarioPage({
Sin calendario editorial
</h2>
<p className="text-sm text-gray-600">
{ctx.label} no tiene un plan RRSS en el portal. Sube un CSV o Excel
para encajar las publicaciones por día.
{ctx.label} no tiene un plan RRSS en el portal. Crea un mes nuevo con
preview editorial o añade publicaciones día a día.
</p>
{agency ? <CalendarUpload tenantLabel={ctx.label} /> : null}
{agency ? (
<CalendarCreator
tenantLabel={ctx.label}
existingMonths={plans.map((p) => p.month).filter(Boolean)}
/>
) : null}
{agency ? (
<details className="text-xs text-gray-500">
<summary className="cursor-pointer font-semibold">
Importar Excel / CSV (opcional)
</summary>
<div className="mt-2">
<CalendarUpload tenantLabel={ctx.label} />
</div>
</details>
) : null}
</div>
);
}

View File

@@ -0,0 +1,767 @@
'use client';
import React, { useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CalendarPlus,
Loader2,
Plus,
Sparkles,
Trash2,
X,
Pencil,
} from 'lucide-react';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { monthLabel } from '@/lib/calendar-import';
const MONTH_NAMES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
function ymNow(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
function addMonths(ym: string, n: number): string {
const [y, m] = ym.split('-').map(Number);
const dt = new Date(Date.UTC(y, m - 1 + n, 1));
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}`;
}
function colorFor(p: RrssPiece): string {
if (p.color) return p.color;
if ((p.kind || '').includes('stor') || /stor/i.test(p.format || '')) {
return '#374151';
}
if ((p.platform || '').includes('linkedin')) return '#7C66B8';
if (/reel/i.test(p.format || '')) return '#7C66B8';
return '#2071cf';
}
type PieceForm = {
id?: string;
date: string;
time: string;
title: string;
platform: string;
format: string;
pillar: string;
hook: string;
body: string;
cta: string;
hashtags: string;
objective: string;
notes_internas: string;
syncPipeline: boolean;
};
function emptyForm(date: string): PieceForm {
return {
date,
time: '10:30',
title: '',
platform: 'instagram',
format: 'Post',
pillar: 'feed',
hook: '',
body: '',
cta: 'Enlace en la bio',
hashtags: '',
objective: '',
notes_internas: '',
syncPipeline: true,
};
}
function fromPiece(p: RrssPiece): PieceForm {
return {
id: p.id,
date: p.date.slice(0, 10),
time: (p.time || '10:30').slice(0, 5),
title: p.title || '',
platform: (p.platform || 'instagram').toLowerCase(),
format: p.format || 'Post',
pillar: p.pillar || 'feed',
hook: p.hook || '',
body: p.body || '',
cta: p.cta || '',
hashtags: p.hashtags || '',
objective: p.objective_metric || '',
notes_internas: '',
syncPipeline: true,
};
}
export default function CalendarCreator({
tenantLabel,
existingMonths = [],
}: {
tenantLabel: string;
existingMonths?: string[];
}) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'pick' | 'edit'>('pick');
const [month, setMonth] = useState(ymNow());
const [plan, setPlan] = useState<RrssMonthPlan | null>(null);
const [rationale, setRationale] = useState('');
const [source, setSource] = useState<string>('');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [dayZoom, setDayZoom] = useState<string | null>(null);
const [form, setForm] = useState<PieceForm | null>(null);
const monthChoices = useMemo(() => {
const base = ymNow();
return Array.from({ length: 6 }, (_, i) => addMonths(base, i));
}, []);
const byDate = useMemo(() => {
const m = new Map<string, RrssPiece[]>();
for (const p of plan?.pieces || []) {
const k = p.date.slice(0, 10);
(m.get(k) || m.set(k, []).get(k)!).push(p);
}
return m;
}, [plan]);
const grid = useMemo(() => {
if (!month) return { pad: 0, days: 0 };
const [y, m] = month.split('-').map(Number);
const first = new Date(Date.UTC(y, m - 1, 1));
const days = new Date(Date.UTC(y, m, 0)).getUTCDate();
const pad = (first.getUTCDay() + 6) % 7;
return { pad, days, y, m };
}, [month]);
async function loadOrCreate(ym: string) {
setBusy(true);
setErr(null);
try {
const r = await fetch(`/api/calendar/month?month=${ym}`);
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'Error');
if (j.plan) {
setPlan(j.plan);
setRationale('Mes ya existente — edita o regenera preview.');
setSource('existing');
} else {
setPlan(null);
setRationale('');
setSource('');
}
setMonth(ym);
setStep('edit');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setBusy(false);
}
}
async function runPreview() {
setBusy(true);
setErr(null);
try {
const r = await fetch('/api/calendar/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ month }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo generar preview');
setPlan(j.plan);
setRationale(j.rationale || '');
setSource(j.source || 'heuristic');
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error preview');
} finally {
setBusy(false);
}
}
async function savePlan(force = false) {
if (!plan) return;
setBusy(true);
setErr(null);
try {
const r = await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: force || source === 'existing' ? 'replace_plan' : 'create',
month,
plan,
force: true,
}),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo guardar');
setPlan(j.plan || plan);
router.refresh();
router.push(`/dashboard/calendario?view=month&month=${month}`);
setOpen(false);
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error al guardar');
} finally {
setBusy(false);
}
}
async function savePiece() {
if (!form || !form.title.trim()) return;
setBusy(true);
setErr(null);
try {
// Ensure month doc exists first
if (!plan) {
await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'create', month }),
});
}
const r = await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'upsert_piece',
month,
syncPipeline: form.syncPipeline,
piece: {
id: form.id,
date: form.date,
time: form.time,
title: form.title,
platform: form.platform,
format: form.format,
pillar: form.pillar,
hook: form.hook || form.title,
body: form.body || form.title,
cta: form.cta,
hashtags: form.hashtags,
objective_metric: form.objective,
kind: /stor/i.test(form.format)
? 'story'
: /reel/i.test(form.format)
? 'reel'
: 'post',
},
}),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo guardar la pieza');
setPlan(j.plan);
setForm(null);
setDayZoom(null);
router.refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error pieza');
} finally {
setBusy(false);
}
}
async function deletePiece(id: string) {
if (!confirm('¿Eliminar esta publicación del calendario?')) return;
setBusy(true);
setErr(null);
try {
const r = await fetch('/api/calendar/month', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'delete_piece', month, pieceId: id }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || 'No se pudo eliminar');
setPlan(j.plan);
setForm(null);
router.refresh();
} catch (e) {
setErr(e instanceof Error ? e.message : 'Error');
} finally {
setBusy(false);
}
}
const dayPieces = dayZoom ? byDate.get(dayZoom) || [] : [];
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
setStep('pick');
setPlan(null);
setErr(null);
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"
>
<CalendarPlus className="h-4 w-4" />
Crear nuevo calendario
</button>
{open ? (
<div className="fixed inset-0 z-[80] flex items-stretch justify-center bg-black/50 p-2 sm:p-4">
<div className="flex w-full max-w-6xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl">
<header className="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-sky-600">
{tenantLabel} · nuevo mes editorial
</p>
<h2 className="text-lg font-extrabold text-gray-900">
{step === 'pick'
? 'Elige el mes'
: `Zoom · ${monthLabel(month)}`}
</h2>
</div>
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</header>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{err ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{err}
</p>
) : null}
{step === 'pick' ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{monthChoices.map((ym) => {
const exists = existingMonths.includes(ym);
const [y, m] = ym.split('-').map(Number);
return (
<button
key={ym}
type="button"
disabled={busy}
onClick={() => void loadOrCreate(ym)}
className={`rounded-2xl border px-4 py-5 text-left transition hover:-translate-y-0.5 ${
exists
? 'border-sky-300 bg-sky-50'
: 'border-gray-200 bg-white hover:border-brand-400'
}`}
>
<p className="text-xs font-bold uppercase tracking-wider text-gray-500">
{y}
</p>
<p className="text-xl font-extrabold text-gray-900">
{MONTH_NAMES[m - 1]}
</p>
<p className="mt-2 text-[11px] text-gray-500">
{exists ? 'Ya existe · abrir zoom' : 'Mes vacío · crear'}
</p>
</button>
);
})}
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setStep('pick')}
className="rounded-lg border border-gray-200 px-3 py-1.5 text-xs font-semibold text-gray-700"
>
← Cambiar mes
</button>
<button
type="button"
disabled={busy}
onClick={() => void runPreview()}
className="inline-flex items-center gap-1.5 rounded-lg bg-violet-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-violet-700 disabled:opacity-60"
>
{busy ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Generar preview editorial
</button>
<button
type="button"
disabled={busy || !plan?.pieces?.length}
onClick={() => void savePlan(true)}
className="rounded-lg bg-gray-900 px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-60"
>
Guardar mes en portal
</button>
<span className="text-[11px] text-gray-500">
{plan?.pieces?.length || 0} piezas
{source ? ` · ${source}` : ''}
</span>
</div>
{rationale ? (
<div className="rounded-xl border border-violet-200 bg-violet-50/70 px-4 py-3 text-xs text-violet-950 leading-relaxed">
<p className="font-bold uppercase tracking-wider text-violet-700 mb-1">
Criterio del preview
</p>
{rationale}
</div>
) : (
<p className="text-xs text-gray-500">
Genera un preview según la marca, estacionalidad e histórico
RRSS del cliente, o haz click en un día para añadir
publicaciones a mano (mismos campos que Pipeline
creatividades).
</p>
)}
{/* Large month zoom */}
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden">
<div className="bg-gradient-to-r from-[#1a2a63] to-[#2071CF] px-4 py-3 text-white flex items-center justify-between">
<span className="text-sm font-extrabold tracking-wider uppercase">
{monthLabel(month)}
</span>
<span className="text-[10px] font-bold uppercase tracking-wider bg-white/20 rounded-full px-2 py-1">
Zoom editorial
</span>
</div>
<div className="grid grid-cols-7 gap-px bg-gray-100 p-px">
{['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom'].map(
(d) => (
<div
key={d}
className="bg-gray-50 py-2 text-center text-[10px] font-bold uppercase text-gray-500"
>
{d}
</div>
)
)}
{Array.from({ length: grid.pad }).map((_, i) => (
<div key={`pad-${i}`} className="bg-gray-50 min-h-[88px]" />
))}
{Array.from({ length: grid.days }).map((_, i) => {
const day = i + 1;
const key = `${month}-${String(day).padStart(2, '0')}`;
const hits = byDate.get(key) || [];
return (
<button
key={key}
type="button"
onClick={() => {
setDayZoom(key);
setForm(null);
}}
className="bg-white min-h-[88px] p-1.5 text-left hover:bg-sky-50/80 transition flex flex-col gap-1"
>
<span className="text-[11px] font-bold text-gray-700">
{day}
</span>
<div className="flex flex-col gap-0.5 flex-1">
{hits.slice(0, 3).map((p) => (
<span
key={p.id}
className="truncate rounded px-1 py-0.5 text-[9px] font-semibold text-white"
style={{ background: colorFor(p) }}
title={p.title}
>
{p.title}
</span>
))}
{hits.length > 3 ? (
<span className="text-[9px] text-gray-400">
+{hits.length - 3}
</span>
) : null}
</div>
</button>
);
})}
</div>
</div>
</>
)}
</div>
</div>
</div>
) : null}
{/* Day drawer */}
{dayZoom ? (
<div className="fixed inset-0 z-[90] flex justify-end bg-black/40">
<div className="h-full w-full max-w-md overflow-y-auto bg-white shadow-2xl">
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3">
<div>
<p className="text-[11px] font-bold uppercase tracking-wider text-gray-500">
Día
</p>
<p className="text-base font-extrabold text-gray-900">{dayZoom}</p>
</div>
<button
type="button"
onClick={() => {
setDayZoom(null);
setForm(null);
}}
className="rounded-lg p-2 hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-4 space-y-3">
{!form ? (
<>
<button
type="button"
onClick={() => setForm(emptyForm(dayZoom))}
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-brand-600 px-3 py-2.5 text-sm font-semibold text-white hover:bg-brand-700"
>
<Plus className="h-4 w-4" />
Añadir publicación
</button>
{dayPieces.length === 0 ? (
<p className="text-xs text-gray-500">
Sin piezas este día. Añade una o genera preview del mes.
</p>
) : (
dayPieces.map((p) => (
<div
key={p.id}
className="rounded-xl border border-gray-200 p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-bold text-gray-900">
{p.title}
</p>
<p className="text-[11px] text-gray-500">
{p.platform} · {p.format} · {p.time || '—'}
{p.pillar ? ` · ${p.pillar}` : ''}
</p>
</div>
<span
className="mt-1 h-2.5 w-2.5 rounded-full shrink-0"
style={{ background: colorFor(p) }}
/>
</div>
<p className="text-xs text-gray-600 line-clamp-3 whitespace-pre-wrap">
{p.body || p.hook}
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setForm(fromPiece(p))}
className="inline-flex flex-1 items-center justify-center gap-1 rounded-lg border border-gray-200 px-2 py-1.5 text-xs font-semibold"
>
<Pencil className="h-3 w-3" /> Editar
</button>
<button
type="button"
onClick={() => void deletePiece(p.id)}
className="inline-flex items-center justify-center gap-1 rounded-lg border border-red-200 px-2 py-1.5 text-xs font-semibold text-red-700"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
</div>
))
)}
</>
) : (
<div className="space-y-3">
<p className="text-xs font-bold uppercase tracking-wider text-gray-500">
{form.id ? 'Editar publicación' : 'Nueva publicación'}
<span className="ml-1 font-normal normal-case text-gray-400">
(mismos campos que Pipeline creatividades)
</span>
</p>
<label className="block text-xs text-gray-600">
Título / nombre
<input
value={form.title}
onChange={(e) =>
setForm({ ...form, title: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="POST 1 · … / REEL · … / STORIES · …"
/>
</label>
<div className="grid grid-cols-2 gap-2">
<label className="block text-xs text-gray-600">
Fecha
<input
type="date"
value={form.date}
onChange={(e) =>
setForm({ ...form, date: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Hora
<input
type="time"
value={form.time}
onChange={(e) =>
setForm({ ...form, time: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Plataforma
<select
value={form.platform}
onChange={(e) =>
setForm({ ...form, platform: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
>
<option value="instagram">Instagram</option>
<option value="facebook">Facebook</option>
<option value="linkedin">LinkedIn</option>
<option value="tiktok">TikTok</option>
</select>
</label>
<label className="block text-xs text-gray-600">
Formato
<select
value={form.format}
onChange={(e) =>
setForm({ ...form, format: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-2 py-2 text-sm"
>
<option value="Post">Post</option>
<option value="Carrusel">Carrusel</option>
<option value="Reel">Reel</option>
<option value="Stories">Stories</option>
</select>
</label>
</div>
<label className="block text-xs text-gray-600">
Pilar
<input
value={form.pillar}
onChange={(e) =>
setForm({ ...form, pillar: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="nuevo-alumno, educativo…"
/>
</label>
<label className="block text-xs text-gray-600">
Hook
<input
value={form.hook}
onChange={(e) =>
setForm({ ...form, hook: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Copy público
<textarea
value={form.body}
onChange={(e) =>
setForm({ ...form, body: e.target.value })
}
rows={5}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
placeholder="Solo texto que irá a redes. Sin WhatsApp / PENDIENTE."
/>
</label>
<label className="block text-xs text-gray-600">
CTA
<input
value={form.cta}
onChange={(e) =>
setForm({ ...form, cta: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Hashtags
<input
value={form.hashtags}
onChange={(e) =>
setForm({ ...form, hashtags: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Objetivo / KPI (interno)
<input
value={form.objective}
onChange={(e) =>
setForm({ ...form, objective: e.target.value })
}
className="mt-1 w-full rounded-lg border border-gray-200 px-3 py-2 text-sm"
/>
</label>
<label className="block text-xs text-gray-600">
Notas internas (nunca al caption)
<textarea
value={form.notes_internas}
onChange={(e) =>
setForm({ ...form, notes_internas: e.target.value })
}
rows={2}
className="mt-1 w-full rounded-lg border border-amber-200 bg-amber-50/50 px-3 py-2 text-sm"
placeholder="WhatsApp, PENDIENTE, assignee…"
/>
</label>
<label className="flex items-center gap-2 text-xs text-gray-700">
<input
type="checkbox"
checked={form.syncPipeline}
onChange={(e) =>
setForm({ ...form, syncPipeline: e.target.checked })
}
/>
Crear / actualizar también en Pipeline creatividades
</label>
<div className="flex gap-2 pt-1">
<button
type="button"
disabled={busy || !form.title.trim()}
onClick={() => void savePiece()}
className="flex-1 rounded-xl bg-gray-900 px-3 py-2.5 text-sm font-semibold text-white disabled:opacity-60"
>
{busy ? 'Guardando…' : 'Guardar publicación'}
</button>
<button
type="button"
onClick={() => setForm(null)}
className="rounded-xl border border-gray-200 px-3 py-2.5 text-sm font-semibold"
>
Cancelar
</button>
</div>
</div>
)}
</div>
</div>
</div>
) : null}
</>
);
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import Link from 'next/link';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import CalendarCreator from '@/components/CalendarCreator';
import CalendarUpload from '@/components/CalendarUpload';
const MONTH_NAMES = [
@@ -210,7 +211,22 @@ export default function RrssArchiveYear({
</div>
</div>
{canUpload ? <CalendarUpload tenantLabel={tenantLabel} /> : null}
{canUpload ? (
<div className="flex flex-wrap items-center gap-3">
<CalendarCreator
tenantLabel={tenantLabel}
existingMonths={plans.map((p) => p.month).filter(Boolean)}
/>
<details className="text-xs text-gray-500">
<summary className="cursor-pointer font-semibold text-gray-600 hover:text-gray-900">
Importar Excel / CSV (opcional)
</summary>
<div className="mt-2">
<CalendarUpload tenantLabel={tenantLabel} />
</div>
</details>
</div>
) : null}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, i) => {

View File

@@ -94,7 +94,18 @@ export function newPieceId(date: string, title: string): string {
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
}
export function emptyMonthPlan(ym: string, tenantLabel: string) {
export function emptyMonthPlan(ym: string, tenantLabel: string): {
kind: 'rrss_month';
month: string;
label: string;
hero: {
eyebrow: string;
title: string;
lede: string;
chips: string[];
};
pieces: RrssPiece[];
} {
return {
kind: 'rrss_month',
month: ym,
@@ -103,7 +114,7 @@ export function emptyMonthPlan(ym: string, tenantLabel: string) {
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
title: `Calendario editorial · ${monthLabel(ym)}`,
lede: 'Piezas del plan editorial.',
chips: [],
chips: [] as string[],
},
pieces: [] as RrssPiece[],
};

View File

@@ -0,0 +1,391 @@
/**
* Generate a month editorial preview from tenant history + brand + seasonality.
* Uses Anthropic when ANTHROPIC_API_KEY is set; otherwise heuristic fallback.
*/
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import { emptyMonthPlan, newPieceId } from '@/lib/calendar-documents';
import { monthLabel, slugify } from '@/lib/calendar-import';
export type TenantBrandBrief = {
slug: string;
label: string;
vertical: string;
tone: string;
pillars: string[];
formats: { post: number; reel: number; stories: number; carrusel: number };
notes: string;
};
const BRANDS: Record<string, Omit<TenantBrandBrief, 'slug' | 'label'>> = {
'talento-y-deporte': {
vertical: 'Educación deportiva / becas USA',
tone: 'Inspirador, cercano, ambicioso; historias de alumnos',
pillars: [
'nuevo-alumno',
'beca-academica',
'beca-deportiva',
'campus',
'objeciones',
'tips-ingles',
],
formats: { post: 0.35, reel: 0.25, stories: 0.3, carrusel: 0.1 },
notes:
'Series Nuevo alumno, objeciones reales, campus USA, English tips. Evitar notas de producción en copy.',
},
'halal-safety': {
vertical: 'Certificación Halal / exportación',
tone: 'Profesional, claro, autoridad técnica',
pillars: ['certificacion', 'exportacion', 'educativo', 'cifras', 'casos'],
formats: { post: 0.45, reel: 0.15, stories: 0.25, carrusel: 0.15 },
notes: 'Cifras, procesos de certificación, mercados, cosmética/alimentación Halal.',
},
'brava-dermoestetica': {
vertical: 'Clínica dermoestética',
tone: 'Cercano, experto, estacional (piel / tratamientos)',
pillars: ['tratamiento', 'educativo', 'antes-despues', 'promos', 'equipo'],
formats: { post: 0.3, reel: 0.25, stories: 0.3, carrusel: 0.15 },
notes: 'Peelings, rutinas, encuestas stories, trends reels, citas en bio.',
},
bps: {
vertical: 'Colegio / idiomas',
tone: 'Familiar, educativo, comunidad',
pillars: ['comunidad', 'educativo', 'eventos', 'familias', 'idiomas'],
formats: { post: 0.35, reel: 0.2, stories: 0.35, carrusel: 0.1 },
notes: 'Blue Parrot School: familias, inglés, eventos escolares.',
},
'blue-parrot-school': {
vertical: 'Colegio / idiomas',
tone: 'Familiar, educativo, comunidad',
pillars: ['comunidad', 'educativo', 'eventos', 'familias', 'idiomas'],
formats: { post: 0.35, reel: 0.2, stories: 0.35, carrusel: 0.1 },
notes: 'Blue Parrot School: familias, inglés, eventos escolares.',
},
noberplast: {
vertical: 'Plásticos industriales B2B',
tone: 'Técnico, B2B, LinkedIn-first',
pillars: ['producto', 'casos', 'industria', 'linkedin'],
formats: { post: 0.5, reel: 0.1, stories: 0.1, carrusel: 0.3 },
notes: 'Más LinkedIn y carruseles técnicos que reels lifestyle.',
},
'4ventures-hub': {
vertical: 'Agencia marketing',
tone: 'Directo, ops, thought leadership',
pillars: ['ops', 'casos', 'tips', 'equipo'],
formats: { post: 0.4, reel: 0.2, stories: 0.2, carrusel: 0.2 },
notes: 'Contenido de agencia y detrás de cámaras.',
},
};
export function brandBrief(slug: string, label: string): TenantBrandBrief {
const base = BRANDS[slug] || {
vertical: 'Marca / servicios',
tone: 'Profesional y claro',
pillars: ['educativo', 'producto', 'comunidad', 'promo'],
formats: { post: 0.4, reel: 0.2, stories: 0.25, carrusel: 0.15 },
notes: 'Ajustar al histórico del cliente.',
};
return { slug, label, ...base };
}
function seasonHints(ym: string): string[] {
const m = Number(ym.slice(5, 7));
const map: Record<number, string[]> = {
1: ['año nuevo', 'propósitos', 'vuelta a la rutina'],
2: ['San Valentín', 'invierno', 'cuidado intensivo'],
3: ['primavera', 'renovación', 'marzo'],
4: ['primavera', 'Semana Santa / puente'],
5: ['mayo', 'comuniones / eventos', 'pre-verano'],
6: ['verano', 'fin de curso', 'export / ferias'],
7: ['verano', 'vacaciones', 'contenido ligero'],
8: ['agosto', 'poco ruido', 'evergreen + tips'],
9: ['vuelta al cole', 'septiembre', 'nuevos ciclos'],
10: ['otoño', 'Halloween', 'rutinas de temporada'],
11: ['Black Friday', 'puente', 'cierre de año comercial'],
12: ['Navidad', 'balance del año', 'agradecimiento'],
};
return map[m] || [];
}
function weekdaysInMonth(ym: string): string[] {
const [y, m] = ym.split('-').map(Number);
const days: string[] = [];
const dim = new Date(Date.UTC(y, m, 0)).getUTCDate();
for (let d = 1; d <= dim; d++) {
const dt = new Date(Date.UTC(y, m - 1, d));
const wd = dt.getUTCDay(); // 0 Sun
if (wd >= 1 && wd <= 5) {
days.push(`${ym}-${String(d).padStart(2, '0')}`);
}
}
return days;
}
function pickFormat(
i: number,
mix: TenantBrandBrief['formats']
): { format: string; kind: string } {
const r = (i * 37) % 100;
const post = mix.post * 100;
const reel = post + mix.reel * 100;
const stories = reel + mix.stories * 100;
if (r < post) return { format: 'Post', kind: 'post' };
if (r < reel) return { format: 'Reel', kind: 'reel' };
if (r < stories) return { format: 'Stories', kind: 'story' };
return { format: 'Carrusel', kind: 'post' };
}
function analyzeHistory(plans: RrssMonthPlan[]): {
avgPerWeek: number;
topPillars: string[];
sampleTitles: string[];
} {
const pieces = plans.flatMap((p) => p.pieces || []);
if (!pieces.length) {
return { avgPerWeek: 4, topPillars: [], sampleTitles: [] };
}
const months = Math.max(1, plans.length);
const avgPerWeek = Math.max(2, Math.round(pieces.length / months / 4));
const pillarCount = new Map<string, number>();
for (const p of pieces) {
const key = (p.pillar || 'feed').toLowerCase();
pillarCount.set(key, (pillarCount.get(key) || 0) + 1);
}
const topPillars = [...pillarCount.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([k]) => k);
const sampleTitles = pieces
.slice(-12)
.map((p) => p.title)
.filter(Boolean);
return { avgPerWeek, topPillars, sampleTitles };
}
function colorFor(format: string, kind: string, platform: string): string {
if (kind === 'story' || /stor/i.test(format)) return '#374151';
if (/linkedin/i.test(platform)) return '#7C66B8';
if (/reel/i.test(format)) return '#7C66B8';
return '#2071cf';
}
export type GeneratePreviewInput = {
ym: string;
slug: string;
label: string;
history: RrssMonthPlan[];
};
export type GeneratePreviewResult = {
plan: RrssMonthPlan;
rationale: string;
source: 'anthropic' | 'heuristic';
};
function heuristicPreview(input: GeneratePreviewInput): GeneratePreviewResult {
const brand = brandBrief(input.slug, input.label);
const hist = analyzeHistory(input.history);
const season = seasonHints(input.ym);
const weekdays = weekdaysInMonth(input.ym);
const perWeek = Math.min(6, Math.max(3, hist.avgPerWeek));
const target = Math.min(weekdays.length, perWeek * 4);
const pillars =
hist.topPillars.length > 0 ? hist.topPillars : brand.pillars;
const pieces: RrssPiece[] = [];
const step = Math.max(1, Math.floor(weekdays.length / target));
for (let i = 0, n = 0; n < target && i < weekdays.length; i += step, n++) {
const date = weekdays[i];
const { format, kind } = pickFormat(n, brand.formats);
const pillar = pillars[n % pillars.length];
const seasonBit = season[n % Math.max(1, season.length)] || '';
let title: string;
if (kind === 'story') {
title = `STORIES · ${seasonBit || pillar}`.slice(0, 120);
} else if (/reel/i.test(format)) {
title = `REEL · ${pillar}${seasonBit ? ` · ${seasonBit}` : ''}`.slice(
0,
120
);
} else if (/carrusel/i.test(format)) {
title = `POST ${n + 1} · Carrusel ${pillar}`.slice(0, 120);
} else if (pillar.includes('nuevo-alumno') || pillar.includes('alumno')) {
title = `Nuevo alumno · historia ${n + 1}`.slice(0, 120);
} else {
title = `POST ${n + 1} · ${pillar}${seasonBit ? ` · ${seasonBit}` : ''}`.slice(
0,
120
);
}
const platform =
pillar.includes('linkedin') || /linkedin/i.test(title)
? 'linkedin'
: 'instagram';
const hook = `${brand.label}: ángulo «${pillar}»${
seasonBit ? ` en clave ${seasonBit}` : ''
}.`;
const body = [
`Borrador editorial para ${monthLabel(input.ym)}.`,
`Pilar: ${pillar}. Tono: ${brand.tone}.`,
seasonBit ? `Estacionalidad: ${seasonBit}.` : '',
'Sustituye este texto por el copy final. No incluyas notas internas (WhatsApp, PENDIENTE, nombres).',
]
.filter(Boolean)
.join('\n\n');
pieces.push({
id: newPieceId(date, title),
date,
time: kind === 'story' ? '09:00' : /reel/i.test(format) ? '18:00' : '10:30',
platform,
format,
pillar,
title,
hook,
body,
cta:
kind === 'story'
? 'Responde a la story'
: /reel/i.test(format)
? 'Guarda este post'
: 'Enlace en la bio',
status: 'planned',
kind,
color: colorFor(format, kind, platform),
});
}
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = `Preview ${brand.vertical}. Ajusta piezas antes de guardar.`;
plan.hero.chips = [
`${pieces.length} piezas`,
...season.slice(0, 2),
brand.vertical,
];
plan.pieces = pieces;
const rationale = [
`Marca: ${brand.label} (${brand.vertical}).`,
`Tono: ${brand.tone}.`,
hist.sampleTitles.length
? `Patrón histórico: ~${hist.avgPerWeek} piezas/semana; pilares frecuentes: ${pillars.join(', ')}.`
: `Sin histórico suficiente → mix por defecto de la vertical.`,
season.length
? `Estacionalidad ${monthLabel(input.ym)}: ${season.join(', ')}.`
: '',
brand.notes,
]
.filter(Boolean)
.join(' ');
return { plan, rationale, source: 'heuristic' };
}
async function anthropicPreview(
input: GeneratePreviewInput,
fallback: GeneratePreviewResult
): Promise<GeneratePreviewResult> {
const key = process.env.ANTHROPIC_API_KEY?.trim();
if (!key || key.startsWith('replace-')) return fallback;
const brand = brandBrief(input.slug, input.label);
const hist = analyzeHistory(input.history);
const season = seasonHints(input.ym);
const prompt = `Eres planner RRSS de 4Ventures. Genera un calendario editorial JSON para ${brand.label} (${input.ym}).
Vertical: ${brand.vertical}. Tono: ${brand.tone}.
Pilares: ${brand.pillars.join(', ')}.
Estacionalidad: ${season.join(', ') || 'ninguna especial'}.
Histórico: ~${hist.avgPerWeek} piezas/semana. Títulos recientes: ${hist.sampleTitles.slice(-8).join(' | ') || 'n/a'}.
Devuelve SOLO JSON: { "rationale": string, "pieces": [{ "date":"YYYY-MM-DD", "time":"HH:MM", "platform":"instagram|facebook|linkedin", "format":"Post|Reel|Stories|Carrusel", "pillar":string, "title":string, "hook":string, "body":string, "cta":string }] }
Reglas: 12-20 piezas en días laborables; body = copy público (sin WhatsApp/PENDIENTE/nombres internos); títulos con POST/REEL/STORIES cuando aplique; español.`;
try {
const Anthropic = (await import('@anthropic-ai/sdk')).default;
const client = new Anthropic({ apiKey: key });
const msg = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 4000,
messages: [{ role: 'user', content: prompt }],
});
const text = msg.content
.map((b) => (b.type === 'text' ? b.text : ''))
.join('');
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (!jsonMatch) return fallback;
const parsed = JSON.parse(jsonMatch[0]) as {
rationale?: string;
pieces?: Array<Partial<RrssPiece>>;
};
if (!Array.isArray(parsed.pieces) || !parsed.pieces.length) return fallback;
const pieces: RrssPiece[] = parsed.pieces
.map((p) => {
const date = String(p.date || '').slice(0, 10);
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !date.startsWith(input.ym)) {
return null;
}
const format = String(p.format || 'Post');
const kind = /stor/i.test(format)
? 'story'
: /reel/i.test(format)
? 'reel'
: 'post';
const title = String(p.title || format).slice(0, 160);
const platform = String(p.platform || 'instagram').toLowerCase();
return {
id: newPieceId(date, title),
date,
time: String(p.time || '10:30').slice(0, 5),
platform,
format,
pillar: String(p.pillar || 'feed'),
title,
hook: String(p.hook || title),
body: String(p.body || title),
cta: String(p.cta || ''),
status: 'planned',
kind,
color: colorFor(format, kind, platform),
} as RrssPiece;
})
.filter(Boolean) as RrssPiece[];
if (!pieces.length) return fallback;
const plan = emptyMonthPlan(input.ym, input.label);
plan.hero.lede = 'Preview generado con IA · revisa antes de guardar.';
plan.hero.chips = [`${pieces.length} piezas`, 'IA', brand.vertical];
plan.pieces = pieces;
return {
plan,
rationale: parsed.rationale || fallback.rationale,
source: 'anthropic',
};
} catch {
return fallback;
}
}
export async function generateMonthPreview(
input: GeneratePreviewInput
): Promise<GeneratePreviewResult> {
const fallback = heuristicPreview(input);
return anthropicPreview(input, fallback);
}
export function mergePreviewIntoPlan(
existing: RrssMonthPlan | null,
preview: RrssMonthPlan,
mode: 'replace' | 'fill_empty'
): RrssMonthPlan {
if (!existing || mode === 'replace' || !existing.pieces?.length) {
return preview;
}
const occupied = new Set(existing.pieces.map((p) => p.date.slice(0, 10)));
const extras = preview.pieces.filter((p) => !occupied.has(p.date.slice(0, 10)));
return {
...existing,
pieces: [...existing.pieces, ...extras].sort((a, b) =>
a.date.localeCompare(b.date)
),
};
}
export { slugify, monthLabel };