feat: upload creativos, PMax formats, portada IG/FB y seguimiento con notas

Permite subir media sin URL; previews Google PMax por ratio; calendario anual estilo Miia con filtros IG/FB y LinkedIn; seguimiento con selector de fechas, KPIs y cuadro de notas.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 09:33:22 +02:00
parent a7704567bd
commit 0d286dc66c
12 changed files with 1295 additions and 150 deletions

View File

@@ -0,0 +1,218 @@
import React from 'react';
import Link from 'next/link';
import type { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
const MONTH_NAMES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
];
function colorFor(piece: RrssPiece): string {
if (piece.color) return piece.color;
if (piece.platform === 'linkedin') return '#7C66B8';
if ((piece.format || '').toLowerCase().includes('stor')) return '#374151';
const hay = `${piece.pillar || ''} ${piece.meta_line || ''} ${piece.title}`.toLowerCase();
if (hay.includes('familia') || hay.includes('bienestar')) return '#E08E2E';
if (hay.includes('sostenib')) return '#0F9D8F';
if (hay.includes('b2b') || hay.includes('hosteler')) return '#7C66B8';
if (hay.includes('prueba') || hay.includes('reseña')) return '#8A93A6';
return '#2071cf';
}
function MiniMonth({
year,
monthIndex,
plan,
}: {
year: number;
monthIndex: number; // 0-11
plan?: RrssMonthPlan;
}) {
const monthKey = `${year}-${String(monthIndex + 1).padStart(2, '0')}`;
const first = new Date(Date.UTC(year, monthIndex, 1));
const daysInMonth = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
const startPad = (first.getUTCDay() + 6) % 7;
const live = Boolean(plan);
const byDate = new Map<string, RrssPiece[]>();
for (const p of plan?.pieces || []) {
const k = p.date.slice(0, 10);
(byDate.get(k) || byDate.set(k, []).get(k)!).push(p);
}
const headerCls = live
? 'flex items-center justify-between gap-2 bg-gradient-to-r from-[#1a2a63] to-[#2071CF] text-white px-3.5 py-2.5'
: 'flex items-center justify-between gap-2 bg-[#9aa6c0] text-white px-3.5 py-2.5';
const body = (
<>
<div className={headerCls}>
<span className="text-[13px] font-extrabold tracking-wider uppercase">
{MONTH_NAMES[monthIndex]}
</span>
<em className="not-italic text-[10px] font-bold tracking-wider uppercase px-2 py-1 rounded-full bg-white/20">
{live ? 'Publicado' : 'Pronto'}
</em>
</div>
<div className="grid grid-cols-7 gap-0.5 px-2.5 pt-3">
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d, i) => (
<span
key={d}
className={`text-center text-[10px] font-bold ${
i >= 5 ? 'text-sky-400' : 'text-gray-500'
}`}
>
{d}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-0.5 px-2.5 pb-2.5 pt-1">
{Array.from({ length: startPad }).map((_, i) => (
<span key={`e-${i}`} className="aspect-square invisible" />
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const key = `${monthKey}-${String(day).padStart(2, '0')}`;
const hits = byDate.get(key) || [];
const weekend =
new Date(Date.UTC(year, monthIndex, day)).getUTCDay() % 6 === 0;
const bg = hits[0] ? colorFor(hits[0]) : undefined;
return (
<span
key={key}
title={hits.map((h) => h.title).join(' · ') || undefined}
className={`aspect-square flex items-center justify-center text-[11px] font-bold rounded-full ${
weekend && !bg ? 'text-sky-400' : 'text-gray-300'
}`}
style={
bg
? {
background: bg,
color: '#fff',
boxShadow: 'inset 0 0 0 1px rgba(255,255,255,.35)',
}
: undefined
}
>
{day}
</span>
);
})}
</div>
<div className="mt-auto px-3.5 py-3 text-xs border-t border-gray-800 text-gray-500">
{live
? `${plan!.pieces.length} piezas · abrir mes`
: 'Pendiente de publicar'}
</div>
</>
);
const className = `rounded-2xl border overflow-hidden flex flex-col min-h-full transition-all ${
live
? 'border-gray-700 bg-[#111827] hover:border-sky-500/50 hover:-translate-y-0.5 shadow-lg shadow-sky-900/10'
: 'border-gray-800/80 bg-[#0D121F] opacity-80'
}`;
if (live) {
return (
<Link
href={`/dashboard/calendario?view=month&month=${monthKey}`}
className={className}
>
{body}
</Link>
);
}
return (
<div className={className} aria-disabled>
{body}
</div>
);
}
/** Year archive cover like /agua-miia/rrss/ig-fb/ */
export default function RrssArchiveYear({
year = 2026,
plans,
tenantLabel,
agencyPublish,
}: {
year?: number;
plans: RrssMonthPlan[];
tenantLabel: string;
agencyPublish?: React.ReactNode;
}) {
const byMonth = new Map(plans.map((p) => [p.month, p]));
const legend = plans[0]?.legend || [
{ label: 'Educativo / serie', color: '#2071cf' },
{ label: 'Familia / bienestar', color: '#E08E2E' },
{ label: 'Sostenibilidad', color: '#0F9D8F' },
{ label: 'B2B / LinkedIn', color: '#7C66B8' },
{ label: 'Stories', color: '#374151' },
];
return (
<div className="space-y-7">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<Link
href="/dashboard/calendario?view=hub"
className="text-xs text-gray-400 hover:text-white"
>
← Hub RRSS
</Link>
<h2 className="text-3xl font-extrabold text-white tracking-tight mt-2">
Calendario IG / FB {year}
</h2>
<p className="text-sm text-gray-400 mt-2 max-w-xl leading-relaxed">
Archivo mensual de contenidos. Los días redondeados y coloreados
marcan el tipo de publicación (misma paleta que en cada mes).
</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="inline-flex items-center gap-2 rounded-full border border-gray-700 bg-[#111827] px-4 py-2.5 text-sm font-bold text-white">
<span className="w-2 h-2 rounded-full bg-sky-400" />
Año {year} · {tenantLabel} × 4Ventures
</div>
{agencyPublish}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 12 }).map((_, i) => {
const key = `${year}-${String(i + 1).padStart(2, '0')}`;
return (
<MiniMonth
key={key}
year={year}
monthIndex={i}
plan={byMonth.get(key)}
/>
);
})}
</div>
<div className="flex flex-wrap gap-4 text-xs text-gray-400">
{legend.map((l) => (
<span key={l.label} className="inline-flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ background: l.color }}
/>
{l.label}
</span>
))}
</div>
</div>
);
}