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:
404
src/app/api/calendar/month/route.ts
Normal file
404
src/app/api/calendar/month/route.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
73
src/app/api/calendar/preview/route.ts
Normal file
73
src/app/api/calendar/preview/route.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import octPlan from '@/data/rrss-octubre-2026.json';
|
|||||||
import { Instagram, Linkedin } from 'lucide-react';
|
import { Instagram, Linkedin } from 'lucide-react';
|
||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
import CalendarUpload from '@/components/CalendarUpload';
|
import CalendarUpload from '@/components/CalendarUpload';
|
||||||
|
import CalendarCreator from '@/components/CalendarCreator';
|
||||||
import {
|
import {
|
||||||
mergePipelineIntoPlans,
|
mergePipelineIntoPlans,
|
||||||
type PipelineCalendarRow,
|
type PipelineCalendarRow,
|
||||||
@@ -179,7 +180,14 @@ export default async function CalendarioPage({
|
|||||||
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
|
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -215,7 +223,14 @@ export default async function CalendarioPage({
|
|||||||
if (active && active.kind === 'rrss_month') {
|
if (active && active.kind === 'rrss_month') {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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
|
<RrssMonthView
|
||||||
plan={active}
|
plan={active}
|
||||||
tenantLabel={ctx.label}
|
tenantLabel={ctx.label}
|
||||||
@@ -239,10 +254,25 @@ export default async function CalendarioPage({
|
|||||||
Sin calendario editorial
|
Sin calendario editorial
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
{ctx.label} no tiene un plan RRSS en el portal. Sube un CSV o Excel
|
{ctx.label} no tiene un plan RRSS en el portal. Crea un mes nuevo con
|
||||||
para encajar las publicaciones por día.
|
preview editorial o añade publicaciones día a día.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
767
src/components/CalendarCreator.tsx
Normal file
767
src/components/CalendarCreator.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
|
||||||
|
import CalendarCreator from '@/components/CalendarCreator';
|
||||||
import CalendarUpload from '@/components/CalendarUpload';
|
import CalendarUpload from '@/components/CalendarUpload';
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
const MONTH_NAMES = [
|
||||||
@@ -210,7 +211,22 @@ export default function RrssArchiveYear({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{Array.from({ length: 12 }).map((_, i) => {
|
{Array.from({ length: 12 }).map((_, i) => {
|
||||||
|
|||||||
@@ -94,7 +94,18 @@ export function newPieceId(date: string, title: string): string {
|
|||||||
return `${date}-${slugify(title || 'pieza')}-${Math.random().toString(36).slice(2, 7)}`;
|
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 {
|
return {
|
||||||
kind: 'rrss_month',
|
kind: 'rrss_month',
|
||||||
month: ym,
|
month: ym,
|
||||||
@@ -103,7 +114,7 @@ export function emptyMonthPlan(ym: string, tenantLabel: string) {
|
|||||||
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
|
eyebrow: `4VENTURES × ${tenantLabel.toUpperCase()} · PLAN RRSS`,
|
||||||
title: `Calendario editorial · ${monthLabel(ym)}`,
|
title: `Calendario editorial · ${monthLabel(ym)}`,
|
||||||
lede: 'Piezas del plan editorial.',
|
lede: 'Piezas del plan editorial.',
|
||||||
chips: [],
|
chips: [] as string[],
|
||||||
},
|
},
|
||||||
pieces: [] as RrssPiece[],
|
pieces: [] as RrssPiece[],
|
||||||
};
|
};
|
||||||
|
|||||||
391
src/lib/calendar-generate.ts
Normal file
391
src/lib/calendar-generate.ts
Normal 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 };
|
||||||
Reference in New Issue
Block a user