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:
84
src/app/api/calendar/publish/route.ts
Normal file
84
src/app/api/calendar/publish/route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { resolveActiveTenant, restGet } from '@/lib/tenant-context';
|
||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||
import octPlan from '@/data/rrss-octubre-2026.json';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST — publish bundled RRSS month plan into documents for the active tenant.
|
||||
* Agency only. Idempotent upsert by title + type.
|
||||
*/
|
||||
export async function POST() {
|
||||
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();
|
||||
if (!headers) {
|
||||
return NextResponse.json({ error: 'no service role' }, { status: 500 });
|
||||
}
|
||||
|
||||
const title = `RRSS ${octPlan.label}`;
|
||||
const tid = encodeURIComponent(ctx.tenantId);
|
||||
const existing = await restGet<Array<{ id: string }>>(
|
||||
`/rest/v1/documents?tenant_id=eq.${tid}&type=eq.calendar&title=eq.${encodeURIComponent(title)}&select=id&limit=1`
|
||||
);
|
||||
|
||||
const payload = {
|
||||
tenant_id: ctx.tenantId,
|
||||
type: 'calendar',
|
||||
title,
|
||||
content: octPlan,
|
||||
};
|
||||
|
||||
let res: Response;
|
||||
if (existing.data?.[0]?.id) {
|
||||
res = await fetch(
|
||||
`${supabaseUrl()}/rest/v1/documents?id=eq.${existing.data[0].id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||
body: JSON.stringify({ content: octPlan, title }),
|
||||
}
|
||||
);
|
||||
} else {
|
||||
res = await fetch(`${supabaseUrl()}/rest/v1/documents`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=representation' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const row = await res.json();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tenant: ctx.label,
|
||||
month: octPlan.month,
|
||||
pieces: (octPlan.pieces || []).length,
|
||||
document: Array.isArray(row) ? row[0] : row,
|
||||
});
|
||||
}
|
||||
66
src/app/dashboard/ad-tracker/page.tsx
Normal file
66
src/app/dashboard/ad-tracker/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/app/dashboard/calendario/PublishRrssButton.tsx
Normal file
42
src/app/dashboard/calendario/PublishRrssButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user