feat: RRSS hub estilo Miia + Ad Tracker por cliente

Replica agua-miia/rrss (hub, archivo, octubre 2026 con 27 piezas) y añade Ad Tracker por tenant al estilo Brava (periodos, Meta+Google, CPL/clics, campañas).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-09-23 02:55:14 +02:00
parent a1a3c26c30
commit b4ee23874e
10 changed files with 2455 additions and 38 deletions

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import { AdDailyRow } from '@/lib/metrics';
import AdTrackerClient from '@/components/AdTrackerClient';
export const dynamic = 'force-dynamic';
const TAGLINES: Record<string, string> = {
'agua miia': 'Agua limpia, en casa, sin complicaciones',
'brava': 'Realza tu belleza natural',
};
function taglineFor(label: string): string | undefined {
const low = label.toLowerCase();
for (const [k, v] of Object.entries(TAGLINES)) {
if (low.includes(k)) return v;
}
return undefined;
}
export default async function AdTrackerPage() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect('/login');
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
if (ctx.portfolioWide) {
return (
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
Selecciona un cliente en Contexto para abrir su Ad Tracker (privado por
tenant).
</p>
);
}
let daily: AdDailyRow[] = [];
if (agency && ctx.viaView && ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const d = await restGet<AdDailyRow[]>(
`/rest/v1/ad_daily?tenant_id=eq.${tid}&select=*&order=date.desc&limit=2000`
);
daily = d.data || [];
} else {
const { data } = await supabase
.from('ad_daily')
.select('*')
.order('date', { ascending: false })
.limit(2000);
daily = (data as AdDailyRow[]) || [];
}
return (
<AdTrackerClient
label={ctx.label}
tagline={taglineFor(ctx.label)}
rows={daily}
/>
);
}

View File

@@ -0,0 +1,42 @@
'use client';
import React, { useState } from 'react';
import { Upload } from 'lucide-react';
export default function PublishRrssButton() {
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
async function publish() {
setBusy(true);
setMsg(null);
try {
const r = await fetch('/api/calendar/publish', { method: 'POST' });
const j = await r.json();
if (!r.ok) {
setMsg(j.error || 'Error al publicar');
return;
}
setMsg(`Publicado · ${j.pieces} piezas · ${j.month}`);
} catch (e) {
setMsg(e instanceof Error ? e.message : 'Error');
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-1">
<button
type="button"
disabled={busy}
onClick={publish}
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl border border-emerald-500/40 bg-emerald-500/15 text-emerald-300 text-sm font-bold hover:bg-emerald-500/25 disabled:opacity-50"
>
<Upload className="w-4 h-4 mr-2" />
{busy ? 'Publicando…' : 'Publicar mes en portal'}
</button>
{msg ? <p className="text-[11px] text-gray-400">{msg}</p> : null}
</div>
);
}

View File

@@ -1,14 +1,37 @@
import React from 'react';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/server';
import CalendarClient from './CalendarClient';
import { CalendarDays } from 'lucide-react';
import { redirect } from 'next/navigation';
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
import { isAgencyRole, userRole } from '@/lib/agency';
import RrssMonthView, { RrssMonthPlan, RrssPiece } from '@/components/RrssMonthView';
import CalendarClient from './CalendarClient';
import PublishRrssButton from './PublishRrssButton';
import octPlan from '@/data/rrss-octubre-2026.json';
import { Instagram, Linkedin, CalendarDays } from 'lucide-react';
export const dynamic = 'force-dynamic';
export default async function CalendarioPage() {
function asPlan(raw: unknown): RrssMonthPlan | null {
if (!raw || typeof raw !== 'object') return null;
const o = raw as Record<string, unknown>;
if (o.kind === 'rrss_month' && Array.isArray(o.pieces)) {
return o as unknown as RrssMonthPlan;
}
return null;
}
function asLegacyPosts(raw: unknown): RrssPiece[] {
if (Array.isArray(raw)) return raw as RrssPiece[];
return [];
}
export default async function CalendarioPage({
searchParams,
}: {
searchParams?: Promise<{ view?: string; month?: string; tab?: string }>;
}) {
const sp = (await searchParams) || {};
const supabase = await createClient();
const {
data: { user },
@@ -18,11 +41,16 @@ export default async function CalendarioPage() {
const ctx = await resolveActiveTenant(user);
const agency = isAgencyRole(userRole(user));
let calendarDocs: Array<{ title?: string; content?: unknown }> = [];
if (ctx.portfolioWide) {
// no-op — prompt to select client
} else if (agency && ctx.viaView && ctx.tenantId) {
return (
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
Selecciona un cliente en Contexto para ver su hub RRSS (privado).
</p>
);
}
let calendarDocs: Array<{ title?: string; content?: unknown }> = [];
if (agency && ctx.viaView && ctx.tenantId) {
const tid = encodeURIComponent(ctx.tenantId);
const r = await restGet<typeof calendarDocs>(
`/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&select=*&order=created_at.desc`
@@ -38,45 +66,152 @@ export default async function CalendarioPage() {
calendarDocs = data || [];
}
let posts: unknown[] = [];
if (calendarDocs.length > 0) {
try {
const raw = calendarDocs[0].content;
if (typeof raw === 'string') posts = JSON.parse(raw);
else if (Array.isArray(raw)) posts = raw;
} catch {
posts = [];
}
// Prefer structured month plans; fallback to bundled Octubre Miia seed
const plans: RrssMonthPlan[] = [];
for (const doc of calendarDocs) {
const raw =
typeof doc.content === 'string'
? (() => {
try {
return JSON.parse(doc.content);
} catch {
return null;
}
})()
: doc.content;
const plan = asPlan(raw);
if (plan) plans.push(plan);
}
if (!plans.length) {
plans.push(octPlan as unknown as RrssMonthPlan);
}
const monthParam = sp.month || plans[0]?.month;
const active = plans.find((p) => p.month === monthParam) || plans[0];
const view = sp.view || 'hub';
const initialPlatform =
sp.tab === 'linkedin'
? 'linkedin'
: sp.tab === 'instagram'
? 'instagram'
: 'all';
// Hub view (like /agua-miia/rrss/)
if (view === 'hub') {
return (
<div className="space-y-8">
<div>
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-400 mb-2">
RRSS · privado
</p>
<h2 className="text-3xl font-extrabold text-white tracking-tight">
{ctx.label}
</h2>
<p className="text-sm text-gray-400 mt-2 max-w-xl">
Calendario Instagram/Facebook y estrategia LinkedIn. Sin publicación
pública: solo portal + doble check agencia/cliente.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link
href="/dashboard/calendario?view=archive"
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#16204A] text-white text-sm font-bold hover:bg-[#1a2a63]"
>
<Instagram className="w-4 h-4 mr-2" /> IG / FB
</Link>
<Link
href="/dashboard/calendario?view=month&month=2026-10&tab=linkedin"
className="inline-flex items-center justify-center min-h-[52px] px-6 rounded-xl bg-[#2071CF] text-white text-sm font-bold hover:bg-[#1a5fb8]"
>
<Linkedin className="w-4 h-4 mr-2" /> LinkedIn
</Link>
{agency ? <PublishRrssButton /> : null}
</div>
</div>
);
}
// Archive of months (like /rrss/ig-fb/)
if (view === 'archive') {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<Link
href="/dashboard/calendario?view=hub"
className="text-xs text-gray-400 hover:text-white"
>
← Hub RRSS
</Link>
<h2 className="text-2xl font-extrabold text-white mt-2">
Calendario IG / FB
</h2>
<p className="text-sm text-gray-400 mt-1">
Archivo mensual renovable · {ctx.label}
</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{plans.map((p) => (
<Link
key={p.month}
href={`/dashboard/calendario?view=month&month=${p.month}`}
className="rounded-2xl border border-gray-800 bg-[#111827] overflow-hidden hover:border-brand-500/40 transition-colors"
>
<div className="bg-gradient-to-r from-[#16204A] to-[#2071CF] px-4 py-3 flex justify-between items-center">
<span className="text-xs font-extrabold tracking-wider text-white uppercase">
{p.label}
</span>
<span className="text-[10px] px-2 py-0.5 rounded-full bg-white/15 text-white">
Live
</span>
</div>
<div className="p-4">
<p className="text-xs text-gray-400">
{(p.pieces || []).length} piezas · clic para abrir el mes
completo (reparto + calendario + copy)
</p>
</div>
</Link>
))}
</div>
</div>
);
}
// Month plan (like /rrss/ig-fb/octubre-2026/)
if (active && active.kind === 'rrss_month') {
return (
<RrssMonthView
plan={active}
tenantLabel={ctx.label}
canCollaborate
initialPlatform={initialPlatform}
/>
);
}
// Legacy flat posts fallback
const legacy = asLegacyPosts(
typeof calendarDocs[0]?.content === 'string'
? JSON.parse(calendarDocs[0].content as string)
: calendarDocs[0]?.content
);
return (
<div className="space-y-8">
<div className="space-y-6">
<div>
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-md bg-brand-950/60 border border-brand-800/40 text-brand-400 text-xs font-semibold uppercase tracking-wider mb-2">
<CalendarDays className="w-3.5 h-3.5" /> RRSS · plan editorial
<CalendarDays className="w-3.5 h-3.5" /> RRSS
</div>
<h2 className="text-2xl font-bold tracking-tight text-white">
<h2 className="text-2xl font-bold text-white">
Calendario editorial — {ctx.label}
</h2>
<p className="text-sm text-gray-400 mt-1 max-w-2xl">
Plan RRSS privado por cliente: vista unificada + pestaña por red.
Cliente y agencia pueden leer, comentar y dar OK (doble check). Sin
publicación pública.
</p>
</div>
{ctx.portfolioWide ? (
<p className="text-sm text-gray-500 border border-gray-800 rounded-xl p-6 bg-[#111827]">
Selecciona un cliente en Contexto para ver su calendario editorial.
</p>
) : (
<CalendarClient
initialPosts={posts as never[]}
calendarTitle={
calendarDocs[0]?.title || 'Sin calendario cargado aún'
}
/>
)}
<CalendarClient
initialPosts={legacy as never[]}
calendarTitle={calendarDocs[0]?.title || 'Calendario'}
/>
</div>
);
}

View File

@@ -10,7 +10,8 @@ import {
LogOut,
UserCheck,
CalendarDays,
Radio
Radio,
Megaphone
} from 'lucide-react';
import { createClient } from '@/lib/supabase/server';
import { logoutAction } from '@/app/auth/actions';
@@ -77,6 +78,13 @@ export default async function DashboardLayout({
<CalendarDays className="w-4 h-4 text-pink-400" />
Calendario Editorial
</Link>
<Link
href="/dashboard/ad-tracker"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"
>
<Megaphone className="w-4 h-4 text-violet-400" />
Ad Tracker
</Link>
<Link
href="/dashboard/activos"
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-800/60 transition-all"