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,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,
});
}

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"

View File

@@ -0,0 +1,663 @@
'use client';
import React, { useMemo, useState } from 'react';
import {
AD_TRACKER_PRESETS,
RangePreset,
normalizePlatform,
resolveAdRange,
} from '@/lib/ad-tracker-range';
import { AdDailyRow, formatEur, formatNum } from '@/lib/metrics';
type PlatformKpis = {
spend: number;
impressions: number;
clicks: number;
conversions: number;
ads: number;
ctr: number | null;
cpc: number | null;
cpl: number | null;
};
type DailyPoint = {
date: string;
metaSpend: number;
googleSpend: number;
metaClicks: number;
googleClicks: number;
metaConv: number;
googleConv: number;
};
type CampaignRow = {
name: string;
spend: number;
impressions: number;
clicks: number;
conversions: number;
cpl: number | null;
};
type Tab = 'resumen' | 'meta' | 'google';
type ChartMode = 'combined' | 'meta' | 'google';
function rates(input: Omit<PlatformKpis, 'ctr' | 'cpc' | 'cpl'>): PlatformKpis {
const { spend, impressions, clicks, conversions, ads } = input;
return {
spend,
impressions,
clicks,
conversions,
ads,
ctr: impressions > 0 ? (100 * clicks) / impressions : null,
cpc: clicks > 0 ? spend / clicks : null,
cpl: conversions > 0 ? spend / conversions : null,
};
}
function convOf(r: AdDailyRow): number {
return Number(r.conversions ?? r.platform_leads ?? 0) || 0;
}
function campaignName(r: AdDailyRow): string {
return r.campaign_name || r.campaign || r.ad_name || 'Sin nombre';
}
function inRange(date: string | undefined, since: string, until: string) {
if (!date) return false;
const d = date.slice(0, 10);
return d >= since && d <= until;
}
function buildSeries(rows: AdDailyRow[]): DailyPoint[] {
const map = new Map<string, DailyPoint>();
for (const r of rows) {
const d = (r.date || '').slice(0, 10);
if (!d) continue;
const cur =
map.get(d) ||
({
date: d,
metaSpend: 0,
googleSpend: 0,
metaClicks: 0,
googleClicks: 0,
metaConv: 0,
googleConv: 0,
} satisfies DailyPoint);
const plat = normalizePlatform(r.platform);
const spend = Number(r.spend || 0);
const clicks = Number(r.clicks || 0);
const conv = convOf(r);
if (plat === 'meta') {
cur.metaSpend += spend;
cur.metaClicks += clicks;
cur.metaConv += conv;
} else if (plat === 'google') {
cur.googleSpend += spend;
cur.googleClicks += clicks;
cur.googleConv += conv;
} else {
// unknown → split weight to meta bucket for visibility
cur.metaSpend += spend;
cur.metaClicks += clicks;
cur.metaConv += conv;
}
map.set(d, cur);
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
function aggregate(rows: AdDailyRow[]): PlatformKpis {
const spend = rows.reduce((a, r) => a + Number(r.spend || 0), 0);
const impressions = rows.reduce((a, r) => a + Number(r.impressions || 0), 0);
const clicks = rows.reduce((a, r) => a + Number(r.clicks || 0), 0);
const conversions = rows.reduce((a, r) => a + convOf(r), 0);
const ads = new Set(
rows.map((r) => campaignName(r)).filter((n) => n && n !== 'Sin nombre')
).size;
return rates({ spend, impressions, clicks, conversions, ads });
}
function byCampaign(rows: AdDailyRow[]): CampaignRow[] {
const map = new Map<string, CampaignRow>();
for (const r of rows) {
const name = campaignName(r);
const cur =
map.get(name) ||
({
name,
spend: 0,
impressions: 0,
clicks: 0,
conversions: 0,
cpl: null,
} satisfies CampaignRow);
cur.spend += Number(r.spend || 0);
cur.impressions += Number(r.impressions || 0);
cur.clicks += Number(r.clicks || 0);
cur.conversions += convOf(r);
map.set(name, cur);
}
return [...map.values()]
.map((c) => ({
...c,
cpl: c.conversions > 0 ? c.spend / c.conversions : null,
}))
.sort((a, b) => b.spend - a.spend);
}
function SparkBars({
data,
mode,
metric,
}: {
data: DailyPoint[];
mode: ChartMode;
metric: 'cpl' | 'spend';
}) {
if (!data.length) {
return (
<p className="text-sm text-gray-500 py-10 text-center">
Sin serie diaria para este rango
</p>
);
}
const values = data.map((d) => {
const spend =
mode === 'meta'
? d.metaSpend
: mode === 'google'
? d.googleSpend
: d.metaSpend + d.googleSpend;
const conv =
mode === 'meta'
? d.metaConv
: mode === 'google'
? d.googleConv
: d.metaConv + d.googleConv;
const clicks =
mode === 'meta'
? d.metaClicks
: mode === 'google'
? d.googleClicks
: d.metaClicks + d.googleClicks;
if (metric === 'cpl') {
return { bar: conv > 0 ? spend / conv : 0, line: conv, spend, clicks };
}
return { bar: spend, line: clicks, spend, clicks };
});
const maxBar = Math.max(...values.map((v) => v.bar), 1);
const maxLine = Math.max(...values.map((v) => v.line), 1);
return (
<div className="space-y-2">
<div className="flex items-end gap-1 h-40 px-1">
{values.map((v, i) => (
<div
key={data[i].date}
className="flex-1 flex flex-col items-center justify-end gap-1 min-w-0 h-full"
title={`${data[i].date}: ${
metric === 'cpl'
? `CPL ${v.bar ? formatEur(v.bar) : '—'} · ${formatNum(v.line)} conv`
: `${formatEur(v.bar)} · ${formatNum(v.line)} clics`
}`}
>
<div
className="w-full rounded-t bg-sky-500/70 relative"
style={{ height: `${Math.max(4, (100 * v.bar) / maxBar)}%` }}
>
<span
className="absolute -top-1 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full bg-amber-300"
style={{
bottom: `${Math.max(0, (100 * v.line) / maxLine)}%`,
}}
/>
</div>
</div>
))}
</div>
<div className="flex justify-between text-[10px] text-gray-500 px-1">
<span>{data[0]?.date.slice(5)}</span>
<span>{data[data.length - 1]?.date.slice(5)}</span>
</div>
</div>
);
}
function KpiTile({
label,
value,
}: {
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-gray-800 bg-[#0D121F] px-3 py-3">
<p className="text-[10px] uppercase tracking-wider text-gray-500">{label}</p>
<p className="mt-1 text-xl font-bold text-white tabular-nums">{value}</p>
</div>
);
}
function CampaignTable({
title,
rows,
empty,
}: {
title: string;
rows: CampaignRow[];
empty: string;
}) {
return (
<section className="rounded-2xl border border-gray-800 bg-[#111827] overflow-hidden">
<div className="px-4 py-3 border-b border-gray-800">
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Campañas activas
</p>
<h3 className="text-lg font-bold text-white">{title}</h3>
</div>
{rows.length === 0 ? (
<p className="px-4 py-8 text-sm text-gray-500">{empty}</p>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wider text-gray-500 bg-black/20">
<tr>
<th className="px-4 py-2 font-medium">Campaña</th>
<th className="px-3 py-2 font-medium">Gasto</th>
<th className="px-3 py-2 font-medium">Clics</th>
<th className="px-3 py-2 font-medium">Conv.</th>
<th className="px-3 py-2 font-medium">CPL</th>
</tr>
</thead>
<tbody>
{rows.slice(0, 25).map((r) => (
<tr key={r.name} className="border-t border-gray-800/80">
<td className="px-4 py-2.5 text-gray-200 max-w-[220px] truncate">
{r.name}
</td>
<td className="px-3 py-2.5 text-white tabular-nums">
{formatEur(r.spend)}
</td>
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
{formatNum(r.clicks)}
</td>
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
{formatNum(r.conversions)}
</td>
<td className="px-3 py-2.5 text-gray-300 tabular-nums">
{r.cpl != null ? formatEur(r.cpl) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export default function AdTrackerClient({
label,
tagline,
rows,
}: {
label: string;
tagline?: string;
rows: AdDailyRow[];
}) {
const [preset, setPreset] = useState<RangePreset>('last_7d');
const [tab, setTab] = useState<Tab>('resumen');
const [chartMode, setChartMode] = useState<ChartMode>('combined');
const range = useMemo(() => resolveAdRange(preset), [preset]);
const filtered = useMemo(
() => rows.filter((r) => inRange(r.date, range.since, range.until)),
[rows, range.since, range.until]
);
const metaRows = filtered.filter((r) => normalizePlatform(r.platform) === 'meta');
const googleRows = filtered.filter(
(r) => normalizePlatform(r.platform) === 'google'
);
// rows without platform count toward combined via meta bucket already in series
const otherRows = filtered.filter(
(r) => normalizePlatform(r.platform) === 'other'
);
const meta = aggregate([...metaRows, ...otherRows]);
const google = aggregate(googleRows);
const combined = rates({
spend: meta.spend + google.spend,
impressions: meta.impressions + google.impressions,
clicks: meta.clicks + google.clicks,
conversions: meta.conversions + google.conversions,
ads: meta.ads + google.ads,
});
const daily = buildSeries(filtered);
const metaCampaigns = byCampaign([...metaRows, ...otherRows]);
const googleCampaigns = byCampaign(googleRows);
const totalSpend = combined.spend || 1;
const metaShare = Math.min(100, (100 * meta.spend) / totalSpend);
const googleShare = Math.min(100, (100 * google.spend) / totalSpend);
const chips: { id: ChartMode; label: string }[] = [
{ id: 'combined', label: 'Conjunto' },
{ id: 'meta', label: 'Meta' },
{ id: 'google', label: 'Google' },
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-400">
Ad tracker · {label}
</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-white mt-1">
{tagline || 'Meta + Google Ads'}
</h2>
<p className="text-sm text-gray-400 mt-2 max-w-2xl">
Resumen agregado de Meta y Google Ads: gasto, clics, conversiones y
campañas activas en el periodo.
</p>
</div>
</div>
{/* Period presets */}
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500 mb-2">
Periodo
</p>
<div className="flex flex-wrap gap-1.5">
{AD_TRACKER_PRESETS.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setPreset(p.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
preset === p.id
? 'bg-brand-500 text-white'
: 'bg-gray-800/70 text-gray-300 hover:bg-gray-800'
}`}
>
{p.label}
</button>
))}
<span className="px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-700 text-gray-300">
{range.label}
</span>
</div>
</div>
{/* Tabs */}
<div className="flex flex-wrap gap-1.5">
{(
[
{ id: 'resumen', label: 'Resumen' },
{ id: 'meta', label: 'Meta Ads' },
{ id: 'google', label: 'Google Ads' },
] as const
).map((t) => (
<button
key={t.id}
type="button"
onClick={() => setTab(t.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
tab === t.id
? 'bg-white text-gray-900'
: 'bg-gray-800/70 text-gray-300'
}`}
>
{t.label}
</button>
))}
</div>
{tab === 'resumen' && (
<div className="space-y-6">
<section className="space-y-4">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Resumen de cuenta
</p>
<h3 className="text-xl font-bold text-white mt-1">
Meta + Google, juntos
</h3>
<p className="text-xs text-gray-500 mt-1 max-w-3xl">
Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL
se recalculan sobre el total (no se suman). Ventana:{' '}
{range.since} → {range.until}.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(combined.spend)} />
<KpiTile
label="Impresiones"
value={formatNum(combined.impressions)}
/>
<KpiTile label="Clics" value={formatNum(combined.clicks)} />
<KpiTile
label="Conversiones"
value={formatNum(combined.conversions)}
/>
<KpiTile
label="CTR"
value={
combined.ctr != null ? `${combined.ctr.toFixed(2)}%` : '—'
}
/>
<KpiTile
label="CPC"
value={combined.cpc != null ? formatEur(combined.cpc) : '—'}
/>
<KpiTile
label="CPL"
value={combined.cpl != null ? formatEur(combined.cpl) : '—'}
/>
<KpiTile label="Ads" value={formatNum(combined.ads)} />
</div>
<div className="h-2.5 rounded-full overflow-hidden border border-gray-800 bg-gray-900 flex">
<div
className="bg-[#7C66B8]"
style={{ width: `${metaShare}%` }}
title={`Meta ${metaShare.toFixed(0)}%`}
/>
<div
className="bg-sky-500"
style={{ width: `${googleShare}%` }}
title={`Google ${googleShare.toFixed(0)}%`}
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<button
type="button"
onClick={() => setTab('meta')}
className="rounded-xl border border-gray-800 bg-[#0D121F] p-4 text-left hover:border-[#7C66B8]/50"
>
<p className="text-[11px] uppercase tracking-wider text-[#a78bfa]">
Meta Ads
</p>
<p className="text-xl font-bold text-white mt-1">
{formatEur(meta.spend)}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatNum(meta.ads)} ads · {formatNum(meta.clicks)} clics ·{' '}
{formatNum(meta.conversions)} conv. · Ver Meta →
</p>
</button>
<button
type="button"
onClick={() => setTab('google')}
className="rounded-xl border border-gray-800 bg-[#0D121F] p-4 text-left hover:border-sky-500/50"
>
<p className="text-[11px] uppercase tracking-wider text-sky-400">
Google Ads
</p>
<p className="text-xl font-bold text-white mt-1">
{formatEur(google.spend)}
</p>
<p className="text-xs text-gray-500 mt-1">
{formatNum(google.ads)} ads · {formatNum(google.clicks)} clics
· {formatNum(google.conversions)} conv. · Ver Google →
</p>
</button>
</div>
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Rendimiento diario
</p>
<h3 className="text-lg font-bold text-white">
Conversiones y CPL
</h3>
<p className="text-xs text-gray-500 mt-1">
Barras: coste por lead. Línea: conversiones. CPL{' '}
{combined.cpl != null ? formatEur(combined.cpl) : '—'}
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setChartMode(c.id)}
className={`px-3 py-1 rounded-full text-[11px] font-medium ${
chartMode === c.id
? 'bg-brand-500 text-white'
: 'border border-gray-700 text-gray-300'
}`}
>
{c.label}
</button>
))}
</div>
</div>
<SparkBars data={daily} mode={chartMode} metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3 mb-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-gray-500">
Rendimiento diario
</p>
<h3 className="text-lg font-bold text-white">Coste y clics</h3>
<p className="text-xs text-gray-500 mt-1">
Barras: coste. Línea: clics. {formatEur(combined.spend)} ·{' '}
{formatNum(combined.clicks)} clics
</p>
</div>
<div className="flex flex-wrap gap-1.5">
{chips.map((c) => (
<button
key={`spend-${c.id}`}
type="button"
onClick={() => setChartMode(c.id)}
className={`px-3 py-1 rounded-full text-[11px] font-medium ${
chartMode === c.id
? 'bg-brand-500 text-white'
: 'border border-gray-700 text-gray-300'
}`}
>
{c.label}
</button>
))}
</div>
</div>
<SparkBars data={daily} mode={chartMode} metric="spend" />
</section>
<div className="grid gap-4 lg:grid-cols-2">
<CampaignTable
title="Meta Ads"
rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo"
/>
<CampaignTable
title="Google Ads"
rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo"
/>
</div>
</div>
)}
{tab === 'meta' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(meta.spend)} />
<KpiTile label="Clics" value={formatNum(meta.clicks)} />
<KpiTile label="Leads" value={formatNum(meta.conversions)} />
<KpiTile
label="CPL"
value={meta.cpl != null ? formatEur(meta.cpl) : '—'}
/>
</div>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Conversiones y CPL · Meta
</h3>
<SparkBars data={daily} mode="meta" metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Coste y clics · Meta
</h3>
<SparkBars data={daily} mode="meta" metric="spend" />
</section>
<CampaignTable
title="Meta Ads"
rows={metaCampaigns}
empty="Sin campañas Meta activas en este periodo"
/>
</div>
)}
{tab === 'google' && (
<div className="space-y-6">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<KpiTile label="Gasto" value={formatEur(google.spend)} />
<KpiTile label="Clics" value={formatNum(google.clicks)} />
<KpiTile label="Conv." value={formatNum(google.conversions)} />
<KpiTile
label="CPL"
value={google.cpl != null ? formatEur(google.cpl) : '—'}
/>
</div>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Conversiones y CPL · Google
</h3>
<SparkBars data={daily} mode="google" metric="cpl" />
</section>
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-4">
<h3 className="text-lg font-bold text-white mb-3">
Coste y clics · Google
</h3>
<SparkBars data={daily} mode="google" metric="spend" />
</section>
<CampaignTable
title="Google Ads"
rows={googleCampaigns}
empty="Sin campañas Google activas en este periodo"
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,630 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import {
Check,
Copy,
Instagram,
Linkedin,
MessageSquare,
ShieldCheck,
X,
} from 'lucide-react';
export type RrssPiece = {
id: string;
date: string;
time?: string;
platform: string;
format?: string;
pillar?: string;
title: string;
hook?: string;
body?: string;
cta?: string;
hashtags?: string;
objective_metric?: string;
status?: string;
meta_line?: string;
kind?: string;
color?: string;
slides?: string[];
};
export type RrssMonthPlan = {
kind?: string;
month: string;
label: string;
hero: {
eyebrow?: string;
title: string;
lede: string;
chips?: string[];
};
reparto_posts?: {
pct: number;
color: string;
label: string;
detail: string;
objective: string;
}[];
reparto_stories?: {
pct: number;
color: string;
label: string;
detail: string;
objective: string;
}[];
legend?: { label: string; color: string; outline?: boolean }[];
pieces: RrssPiece[];
};
type Dual = {
agency: boolean;
client: boolean;
both: boolean;
label: string;
};
type Comment = {
id: string;
body: string;
author_email?: string | null;
author_party: 'agency' | 'client';
created_at: string;
};
const PILLAR_COLORS: Record<string, string> = {
educativo: '#2071cf',
serie: '#2071cf',
familia: '#E08E2E',
bienestar: '#E08E2E',
sostenibilidad: '#0F9D8F',
b2b: '#7C66B8',
hostelería: '#7C66B8',
prueba: '#8A93A6',
linkedin: '#7C66B8',
};
function colorFor(piece: RrssPiece): string {
if (piece.color) return piece.color;
if (piece.platform === 'linkedin') return '#7C66B8';
const hay = `${piece.pillar || ''} ${piece.meta_line || ''} ${piece.title}`.toLowerCase();
for (const [k, c] of Object.entries(PILLAR_COLORS)) {
if (hay.includes(k)) return c;
}
return '#2071cf';
}
function DualBadge({ dual }: { dual?: Dual }) {
if (!dual) {
return (
<span className="text-[10px] px-2 py-0.5 rounded-full border border-white/10 text-white/50">
Pendiente
</span>
);
}
if (dual.both) {
return (
<span className="inline-flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
<ShieldCheck className="w-3 h-3" /> Doble OK
</span>
);
}
return (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-amber-500/15 text-amber-200 border border-amber-500/25">
{dual.label}
</span>
);
}
export default function RrssMonthView({
plan,
tenantLabel,
canCollaborate = true,
initialPlatform = 'all',
}: {
plan: RrssMonthPlan;
tenantLabel: string;
canCollaborate?: boolean;
initialPlatform?: 'all' | string;
}) {
const pieces = plan.pieces || [];
const [platform, setPlatform] = useState<'all' | string>(initialPlatform);
const [selected, setSelected] = useState<RrssPiece | null>(null);
const [dualMap, setDualMap] = useState<Record<string, Dual>>({});
const [comments, setComments] = useState<Comment[]>([]);
const [commentText, setCommentText] = useState('');
const [party, setParty] = useState<'agency' | 'client'>('client');
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const platforms = useMemo(() => {
const set = new Set(pieces.map((p) => p.platform));
return ['all', ...[...set].sort()];
}, [pieces]);
const filtered = pieces.filter(
(p) => platform === 'all' || p.platform === platform
);
const ids = pieces.map((p) => p.id);
const refreshDual = useCallback(async () => {
if (!canCollaborate || !ids.length) return;
const r = await fetch(
`/api/calendar/collab?ids=${ids.map(encodeURIComponent).join(',')}`
);
const j = await r.json();
if (r.ok) {
setDualMap(j.dual || {});
if (j.party) setParty(j.party);
}
}, [canCollaborate, ids.join(',')]);
useEffect(() => {
refreshDual();
}, [refreshDual]);
useEffect(() => {
if (!selected || !canCollaborate) {
setComments([]);
return;
}
(async () => {
const r = await fetch(
`/api/calendar/collab?ids=${encodeURIComponent(selected.id)}&resourceId=${encodeURIComponent(selected.id)}`
);
const j = await r.json();
if (r.ok) {
setComments(j.comments || []);
if (j.dual?.[selected.id]) {
setDualMap((prev) => ({ ...prev, [selected.id]: j.dual[selected.id] }));
}
}
})();
}, [selected, canCollaborate]);
// Month calendar grid
const year = Number(plan.month.slice(0, 4));
const monthNum = Number(plan.month.slice(5, 7));
const first = new Date(Date.UTC(year, monthNum - 1, 1));
const daysInMonth = new Date(Date.UTC(year, monthNum, 0)).getUTCDate();
// Monday-first offset
const startPad = (first.getUTCDay() + 6) % 7;
const byDate = useMemo(() => {
const m = new Map<string, RrssPiece[]>();
for (const p of filtered) {
const k = p.date.slice(0, 10);
(m.get(k) || m.set(k, []).get(k)!).push(p);
}
return m;
}, [filtered]);
async function approve(action: 'approve' | 'revoke') {
if (!selected) return;
setBusy(true);
try {
const r = await fetch('/api/calendar/collab', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, postId: selected.id }),
});
const j = await r.json();
if (r.ok) setDualMap((prev) => ({ ...prev, [selected.id]: j.dual }));
} finally {
setBusy(false);
}
}
async function sendComment() {
if (!selected || !commentText.trim()) return;
setBusy(true);
try {
const r = await fetch('/api/calendar/collab', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'comment',
postId: selected.id,
body: commentText.trim(),
}),
});
const j = await r.json();
if (r.ok) {
setComments((prev) => [...prev, j.comment]);
setCommentText('');
}
} finally {
setBusy(false);
}
}
const myOk =
selected && dualMap[selected.id]
? party === 'agency'
? dualMap[selected.id].agency
: dualMap[selected.id].client
: false;
return (
<div className="space-y-6">
{/* Hub crumbs */}
<div className="flex flex-wrap items-center justify-between gap-2 text-xs text-gray-400">
<div>
<Link href="/dashboard/calendario" className="hover:text-white">
RRSS
</Link>
<span className="mx-1.5">/</span>
<Link href="/dashboard/calendario?view=archive" className="hover:text-white">
Archivo
</Link>
<span className="mx-1.5">/</span>
<span className="text-white">{plan.label}</span>
</div>
<span className="text-[11px] text-gray-500">
{tenantLabel} · privado · doble check
</span>
</div>
{/* Hero */}
<section className="relative overflow-hidden rounded-2xl border border-gray-800 bg-gradient-to-br from-[#16204A] via-[#111827] to-[#0B0F19] p-6 md:p-8">
<p className="text-[11px] font-bold tracking-[0.16em] uppercase text-sky-300/90">
{plan.hero.eyebrow || '4VENTURES · PLAN OPERATIVO'}
</p>
<h2 className="mt-2 text-2xl md:text-3xl font-extrabold text-white tracking-tight">
{plan.hero.title}
</h2>
<p className="mt-3 text-sm text-slate-300 max-w-2xl leading-relaxed">
{plan.hero.lede}
</p>
<div className="mt-4 flex flex-wrap gap-2">
{(plan.hero.chips || []).map((c) => (
<span
key={c}
className="text-[11px] px-3 py-1.5 rounded-full border border-white/15 bg-white/5 text-slate-200"
>
{c}
</span>
))}
</div>
</section>
{/* Network tabs */}
<div className="flex flex-wrap gap-1.5">
{platforms.map((p) => (
<button
key={p}
type="button"
onClick={() => setPlatform(p)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold ${
platform === p
? 'bg-brand-500 text-white'
: 'bg-gray-800/70 text-gray-300'
}`}
>
{p === 'all'
? `Unificado (${pieces.length})`
: `${p} (${pieces.filter((x) => x.platform === p).length})`}
</button>
))}
</div>
{/* Reparto posts */}
{(plan.reparto_posts || []).length > 0 && (
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-5 md:p-6">
<h3 className="text-lg font-bold text-white">
1 · Reparto de los posts del mes
</h3>
<p className="text-xs text-gray-500 mt-1 mb-4">
Porcentaje del mes · objetivo de métrica a la derecha
</p>
<div className="space-y-3">
{(plan.reparto_posts || []).map((r) => (
<div
key={r.label}
className="grid grid-cols-[64px_1fr] md:grid-cols-[72px_220px_1fr_180px] gap-3 items-center"
>
<span
className="text-2xl font-extrabold"
style={{ color: r.color }}
>
{r.pct}%
</span>
<div>
<p className="text-sm font-semibold text-white">{r.label}</p>
<p className="text-[11px] text-gray-500">{r.detail}</p>
</div>
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${r.pct}%`, background: r.color }}
/>
</div>
<p className="text-xs text-gray-300">
Objetivo: <strong>{r.objective}</strong>
</p>
</div>
))}
</div>
</section>
)}
{(plan.reparto_stories || []).length > 0 && (
<section className="rounded-2xl border border-gray-800 bg-[#111827] p-5 md:p-6">
<h3 className="text-lg font-bold text-white">
2 · Reparto de las Stories del mes
</h3>
<div className="space-y-3 mt-4">
{(plan.reparto_stories || []).map((r) => (
<div
key={r.label}
className="grid grid-cols-[64px_1fr] md:grid-cols-[72px_220px_1fr_180px] gap-3 items-center"
>
<span
className="text-2xl font-extrabold"
style={{ color: r.color }}
>
{r.pct}%
</span>
<div>
<p className="text-sm font-semibold text-white">{r.label}</p>
<p className="text-[11px] text-gray-500">{r.detail}</p>
</div>
<div className="hidden md:block h-3 rounded-full bg-gray-900 overflow-hidden">
<div
className="h-full rounded-full"
style={{ width: `${r.pct}%`, background: r.color }}
/>
</div>
<p className="text-xs text-gray-300">
Objetivo: <strong>{r.objective}</strong>
</p>
</div>
))}
</div>
</section>
)}
{/* Legend */}
{(plan.legend || []).length > 0 && (
<div className="flex flex-wrap gap-2">
{(plan.legend || []).map((l) => (
<span
key={l.label}
className="text-[11px] px-2.5 py-1 rounded-lg font-semibold"
style={
l.outline
? {
color: l.color,
border: `1px solid ${l.color}`,
background: 'transparent',
}
: { background: l.color, color: '#fff' }
}
>
{l.label}
</span>
))}
</div>
)}
{/* Month grid */}
<section className="rounded-2xl border border-gray-800 bg-[#0D121F] p-4 md:p-5">
<h3 className="text-lg font-bold text-white mb-1">
El mes de un vistazo
</h3>
<p className="text-xs text-gray-500 mb-4">
Cada tarjeta de color es una publicación; clic para ver copy, aprobar y
comentar.
</p>
<div className="grid grid-cols-7 gap-1.5 mb-1.5">
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => (
<div
key={d}
className="text-center text-[11px] font-bold text-white bg-[#16204A] rounded-lg py-2"
>
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1.5">
{Array.from({ length: startPad }).map((_, i) => (
<div
key={`pad-${i}`}
className="min-h-[88px] rounded-lg border border-dashed border-gray-800/80 bg-transparent"
/>
))}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const key = `${plan.month}-${String(day).padStart(2, '0')}`;
const dayPieces = byDate.get(key) || [];
return (
<div
key={key}
className="min-h-[88px] rounded-lg border border-gray-800 bg-[#111827] p-1.5"
>
<p className="text-[11px] font-bold text-gray-300 mb-1">{day}</p>
<div className="space-y-1">
{dayPieces.slice(0, 3).map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelected(p)}
className="w-full text-left rounded-md px-1.5 py-1 text-[9px] font-semibold leading-tight text-white"
style={{ background: colorFor(p) }}
title={p.title}
>
{p.platform === 'linkedin' ? 'LI' : 'IG'} ·{' '}
{p.title.replace(/^[^·]+·\s*/, '').slice(0, 42)}
</button>
))}
{dayPieces.length > 3 && (
<p className="text-[9px] text-gray-500">
+{dayPieces.length - 3}
</p>
)}
</div>
</div>
);
})}
</div>
</section>
{/* Piece by piece */}
<section className="space-y-3">
<h3 className="text-lg font-bold text-white">
Contenido completo, pieza a pieza
</h3>
{filtered.map((p) => (
<button
key={p.id}
type="button"
onClick={() => setSelected(p)}
className="w-full text-left rounded-xl border border-gray-800 bg-[#111827] p-4 hover:border-gray-700"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex flex-wrap items-center gap-2 mb-1">
<span
className="text-[10px] font-bold px-2 py-0.5 rounded text-white"
style={{ background: colorFor(p) }}
>
{p.platform === 'linkedin' ? (
<span className="inline-flex items-center gap-1">
<Linkedin className="w-3 h-3" /> LinkedIn
</span>
) : (
<span className="inline-flex items-center gap-1">
<Instagram className="w-3 h-3" /> Instagram
</span>
)}
</span>
<span className="text-[11px] text-gray-500 font-mono">
{p.date} · {p.format}
</span>
<DualBadge dual={dualMap[p.id]} />
</div>
<p className="text-sm font-bold text-white">{p.title}</p>
{p.objective_metric && (
<p className="text-xs text-gray-400 mt-1">
Objetivo: {p.objective_metric}
</p>
)}
<p className="text-xs text-gray-500 mt-2 line-clamp-2">
{p.hook || p.body}
</p>
</div>
</div>
</button>
))}
</section>
{selected && (
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
<div className="bg-[#0D121F] border border-gray-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-gray-800 flex justify-between gap-3 bg-[#111827]">
<div>
<p className="text-sm font-bold text-white">{selected.title}</p>
<p className="text-[11px] text-gray-500 font-mono mt-0.5">
{selected.platform} · {selected.date} · {selected.format}
</p>
</div>
<button type="button" onClick={() => setSelected(null)}>
<X className="w-5 h-5 text-gray-400" />
</button>
</div>
<div className="p-5 overflow-y-auto space-y-3 text-sm">
<DualBadge dual={dualMap[selected.id]} />
{selected.meta_line && (
<p className="text-xs text-gray-400">{selected.meta_line}</p>
)}
<div className="rounded-xl border border-gray-800 bg-gray-900/60 p-3 text-amber-200 text-sm">
{selected.hook}
</div>
<div className="rounded-xl border border-gray-800 bg-gray-900/40 p-3 text-gray-200 whitespace-pre-wrap text-xs leading-relaxed">
{selected.body}
</div>
{selected.cta && (
<p className="text-xs text-brand-300">👉 {selected.cta}</p>
)}
{selected.hashtags && (
<p className="text-xs text-sky-400 font-mono">{selected.hashtags}</p>
)}
{canCollaborate && (
<div className="rounded-xl border border-gray-800 p-3 space-y-2">
<p className="text-xs font-semibold text-white inline-flex items-center gap-1.5">
<MessageSquare className="w-3.5 h-3.5 text-sky-400" />{' '}
Comentarios
</p>
<ul className="space-y-2 max-h-36 overflow-y-auto">
{comments.map((c) => (
<li
key={c.id}
className="text-xs border border-gray-800 rounded-lg px-2 py-1.5"
>
<p className="text-[10px] text-gray-500">
{c.author_party} · {c.author_email}
</p>
<p className="text-gray-200 whitespace-pre-wrap">
{c.body}
</p>
</li>
))}
</ul>
<div className="flex gap-2">
<input
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
className="flex-1 rounded-lg bg-black/40 border border-gray-700 px-3 py-2 text-xs text-white"
placeholder="Comentario…"
/>
<button
type="button"
disabled={busy || !commentText.trim()}
onClick={sendComment}
className="px-3 py-2 rounded-lg bg-sky-500/20 text-sky-300 text-xs border border-sky-500/30"
>
Enviar
</button>
</div>
</div>
)}
</div>
<div className="p-4 border-t border-gray-800 bg-[#111827] flex flex-wrap justify-between gap-2">
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(
`${selected.hook}\n\n${selected.body}\n\n${selected.cta}\n\n${selected.hashtags}`
);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}}
className="px-3 py-2 rounded-xl bg-gray-800 text-xs text-gray-200 inline-flex items-center gap-1.5"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
Copiar copy
</button>
{canCollaborate && (
<button
type="button"
disabled={busy}
onClick={() => approve(myOk ? 'revoke' : 'approve')}
className="px-4 py-2 rounded-xl text-xs font-semibold bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 inline-flex items-center gap-1.5"
>
<ShieldCheck className="w-3.5 h-3.5" />
{myOk
? 'Quitar mi OK'
: `Aprobar (${party === 'agency' ? 'agencia' : 'cliente'})`}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,630 @@
{
"kind": "rrss_month",
"month": "2026-10",
"label": "Octubre 2026",
"hero": {
"eyebrow": "4VENTURES × AGUA MIIA · PLAN OPERATIVO",
"title": "Calendario de contenidos · Octubre 2026",
"lede": "Instagram (feed + Stories) y LinkedIn · Reparto del mes, calendario por colores y contenido desarrollado pieza a pieza. Cadencia: 2 posts + 3 tandas de Stories por semana.",
"chips": [
"Producto estrella: Grifo Flujo directo",
"9 posts IG · 14 Stories · 4 LinkedIn",
"Serie «Los miércoles del agua»"
]
},
"reparto_posts": [
{
"pct": 44,
"color": "#2071cf",
"label": "Educativo · serie «Los miércoles del agua»",
"detail": "4 piezas — todos los miércoles",
"objective": "guardados"
},
{
"pct": 22,
"color": "#D9821F",
"label": "Familia y bienestar",
"detail": "2 piezas — vídeo (V 23) + carrusel de comodidad (V 30)",
"objective": "alcance + nuevos seguidores"
},
{
"pct": 11,
"color": "#0F9D8F",
"label": "Sostenibilidad y facilidad",
"detail": "1 pieza — V 2",
"objective": "guardados + reenvíos"
},
{
"pct": 11,
"color": "#7C66B8",
"label": "B2B · empresas",
"detail": "1 pieza en IG (hostelería) + 4 posts en LinkedIn",
"objective": "DMs + clic al destacado «Empresas»"
},
{
"pct": 11,
"color": "#8A93A6",
"label": "Prueba social",
"detail": "1 pieza — captura de una reseña real (V 16)",
"objective": "confianza + visitas al perfil"
}
],
"reparto_stories": [
{
"pct": 43,
"color": "#2071cf",
"label": "Interactivas puras",
"detail": "6 tandas — encuestas, quizzes y sliders que calientan un post",
"objective": "respuestas al sticker"
},
{
"pct": 21,
"color": "#D9821F",
"label": "De marca / lifestyle",
"detail": "3 tandas — «primer café», «jarra llena», cierre de finde",
"objective": "respuestas al slider"
},
{
"pct": 14,
"color": "#16204a",
"label": "Educativas",
"detail": "2 tandas — dato + caja de preguntas",
"objective": "preguntas en la caja"
},
{
"pct": 14,
"color": "#5a6480",
"label": "Comunidad",
"detail": "2 tandas — cajas de preguntas que alimentan un post",
"objective": "preguntas + DMs"
},
{
"pct": 7,
"color": "#8A93A6",
"label": "Prueba social",
"detail": "1 tanda — reseña real (S 17)",
"objective": "respuestas + confianza"
}
],
"legend": [
{
"label": "Serie «miércoles del agua»",
"color": "#2071cf"
},
{
"label": "Familia / bienestar",
"color": "#E08E2E"
},
{
"label": "Sostenibilidad",
"color": "#0F9D8F"
},
{
"label": "B2B",
"color": "#7C66B8"
},
{
"label": "Prueba social",
"color": "#8A93A6"
},
{
"label": "LinkedIn B2B",
"color": "#7C66B8",
"outline": true
},
{
"label": "Stories",
"color": "#374151"
}
],
"pieces": [
{
"id": "oct26-instagram-story-1",
"date": "2026-10-01",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Interactiva",
"title": "J 1 · Stories — Encuesta de arranque: ¿de dónde bebes el agua en casa?",
"hook": "Encuesta de arranque: ¿de dónde bebes el agua en casa?",
"body": "Sticker: Sí · encuesta 3 opciones\nNaturaleza: Interactiva\nObjetivo: Respuestas + arranque de conversación del mes",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas + arranque de conversación del mes",
"status": "draft",
"meta_line": "Stories · Sí · encuesta 3 opciones · Interactiva",
"kind": "story",
"color": "#2071cf",
"slides": []
},
{
"id": "oct26-instagram-2-0",
"date": "2026-10-02",
"time": "10:30",
"platform": "instagram",
"format": "carrusel",
"pillar": "Línea: Sostenibilidad y facilidad",
"title": "VIERNES 2 · Carrusel sostenibilidad — «El plástico que dejas de traer a casa»",
"hook": "Slide 1 · HOOK: «Cada semana subes a casa botellas de agua. ¿Sabes cuánto plástico es al año?» Slide 2: Una familia que bebe agua embotellada acumula cientos de envases de plástico al año, solo para beber. Y fabricarlos, transportarlos y reciclarlos también deja huella. Slide 3: Con Miia ese plástico desaparece: agua purificada y remineralizada directa de tu grifo, ilimitada. Sin botellas de un solo uso, sin garrafas. Slide 4: Menos plástico en casa, menos peso que cargar y menos residuos que bajar. Bueno para tu cocina y para el planeta. Slide 5 · CIERRE (plantilla guardado): «Guárdate este post si quieres reducir el plástico de tu casa este año.» Caption: El plástico que dejas de traer a casa cuando el agua sale de tu grifo: sin botellas de un solo uso ni garrafas. Agua purificada y remineralizada por suscripción. Menos plástico, menos residuos. Nota: línea de sostenibilidad (pilar de marca). Pieza nueva —no repite la cuenta de ahorro de familia de septiembre. Nunca «agua pura». Cierre en la plantilla de guardado.",
"body": "Slide 1 · HOOK: «Cada semana subes a casa botellas de agua. ¿Sabes cuánto plástico es al año?» Slide 2: Una familia que bebe agua embotellada acumula cientos de envases de plástico al año, solo para beber. Y fabricarlos, transportarlos y reciclarlos también deja huella. Slide 3: Con Miia ese plástico desaparece: agua purificada y remineralizada directa de tu grifo, ilimitada. Sin botellas de un solo uso, sin garrafas. Slide 4: Menos plástico en casa, menos peso que cargar y menos residuos que bajar. Bueno para tu cocina y para el planeta. Slide 5 · CIERRE (plantilla guardado): «Guárdate este post si quieres reducir el plástico de tu casa este año.» Caption: El plástico que dejas de traer a casa cuando el agua sale de tu grifo: sin botellas de un solo uso ni garrafas. Agua purificada y remineralizada por suscripción. Menos plástico, menos residuos. Nota: línea de sostenibilidad (pilar de marca). Pieza nueva —no repite la cuenta de ahorro de familia de septiembre. Nunca «agua pura». Cierre en la plantilla de guardado.",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "guardados + reenvíos",
"status": "draft",
"meta_line": "Línea: Sostenibilidad y facilidad · Formato: carrusel 5 slides · Objetivo: guardados + reenvíos",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-3",
"date": "2026-10-03",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "De marca",
"title": "S 3 · Stories — Lifestyle: el primer café del sábado, sin cal",
"hook": "Lifestyle: el primer café del sábado, sin cal",
"body": "Sticker: Sí · slider\nNaturaleza: De marca\nObjetivo: Respuestas al slider · refuerza la pieza del V 2",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas al slider · refuerza la pieza del V 2",
"status": "draft",
"meta_line": "Stories · Sí · slider · De marca",
"kind": "story",
"color": "#E08E2E",
"slides": []
},
{
"id": "oct26-linkedin-5",
"date": "2026-10-05",
"time": "09:00",
"platform": "linkedin",
"format": "post",
"pillar": "B2B",
"title": "LUNES 5 · B2B 1 — «El coste real del agua en una oficina (más allá de la factura)»",
"hook": "En la mayoría de oficinas el agua es un gasto invisible: cuota del dispensador, garrafas de reposición, pedidos recurrentes y un rincón de almacenaje. Cada garrafa son ~20 kg que a",
"body": "En la mayoría de oficinas el agua es un gasto invisible: cuota del dispensador, garrafas de reposición, pedidos recurrentes y un rincón de almacenaje. Cada garrafa son ~20 kg que alguien mueve.\nCon una fuente Miia el modelo cambia: suscripción mensual con cuota fija, sin fianza y sin permanencia; agua purificada y remineralizada ilimitada, fría o natural; y mantenimiento incluido. Menos logística, coste previsible y cero garrafas esperando recogida.\n¿Cuánto os cuesta hoy el agua en la oficina, contando pedidos, almacenaje y tiempo? Os hacemos un estudio de ahorro gratuito.",
"cta": "¿Hablamos? Estudio de ahorro gratuito.",
"hashtags": "#AguaMiia #B2B #Oficinas #Sostenibilidad",
"objective_metric": "interacción y contactos B2B (oficinas y coworkings).",
"status": "draft",
"meta_line": "LinkedIn B2B · Objetivo: interacción y contactos B2B (oficinas y coworkings).",
"kind": "post",
"color": "#7C66B8"
},
{
"id": "oct26-instagram-story-6",
"date": "2026-10-06",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Interactiva",
"title": "M 6 · Stories — Encuesta: ¿sabías que tu grifo Miia da dos aguas distintas? sí / no",
"hook": "Encuesta: ¿sabías que tu grifo Miia da dos aguas distintas? sí / no",
"body": "Sticker: Sí · encuesta\nNaturaleza: Interactiva\nObjetivo: Respuestas · calienta la serie del X 7\nFrame 2 · Respuesta: sí — una salida da agua filtrada (lavar fruta y verdura) y otra, purificada y remineralizada para beber. Hoy lo contamos en el post.",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas · calienta la serie del X 7",
"status": "draft",
"meta_line": "Stories · Sí · encuesta · Interactiva",
"kind": "story",
"color": "#2071cf",
"slides": [
"Frame 2 · Respuesta: sí — una salida da agua filtrada (lavar fruta y verdura) y otra, purificada y remineralizada para beber. Hoy lo contamos en el post."
]
},
{
"id": "oct26-instagram-7-1",
"date": "2026-10-07",
"time": "10:30",
"platform": "instagram",
"format": "carrusel",
"pillar": "Línea: Educativo/serie (producto)",
"title": "MIÉRCOLES 7 · Carrusel «Los miércoles del agua» 4 — «¿Filtrada o purificada? Las dos aguas de tu grifo Miia»",
"hook": "Slide 1 · HOOK: «Tu grifo Miia da dos aguas distintas. ¿Sabes para qué sirve cada una?» Slide 2: [foto del grifo Miia de dos salidas] Salida izquierda (PURIFIED): agua filtrada solo por el filtro CP (sedimentos y carbón). No está osmotizada ni remineralizada. Ideal para lavar fruta y verdura. Slide 3: Salida derecha (DRINK): agua que ha pasado por los 3 filtros —CP, RO y TC—. Purificada y remineralizada. Es la que bebes y con la que cocinas. Slide 4: Por qué importa: un mismo grifo, dos usos. Reservas el agua de máxima calidad para beber y usas la filtrada para lo cotidiano. [flecha señalando cada salida] Slide 5 · CIERRE (plantilla guardado): «Dos aguas, un grifo. Guárdatelo para tu cocina. Los miércoles, agua.» Caption: El grifo Miia tiene dos salidas y dos aguas: una filtrada para lavar alimentos y otra purificada y remineralizada para beber. Te contamos cuál es cuál. Ósmosis inversa en casa. Nota: pieza B2C anclada al producto (grifo de dos salidas del dossier). Distinguir «filtrada» (solo CP) de «purificada y remineralizada» (CP+RO+TC). «Miia» siempre logotipo; nunca «agua pura».",
"body": "Slide 1 · HOOK: «Tu grifo Miia da dos aguas distintas. ¿Sabes para qué sirve cada una?» Slide 2: [foto del grifo Miia de dos salidas] Salida izquierda (PURIFIED): agua filtrada solo por el filtro CP (sedimentos y carbón). No está osmotizada ni remineralizada. Ideal para lavar fruta y verdura. Slide 3: Salida derecha (DRINK): agua que ha pasado por los 3 filtros —CP, RO y TC—. Purificada y remineralizada. Es la que bebes y con la que cocinas. Slide 4: Por qué importa: un mismo grifo, dos usos. Reservas el agua de máxima calidad para beber y usas la filtrada para lo cotidiano. [flecha señalando cada salida] Slide 5 · CIERRE (plantilla guardado): «Dos aguas, un grifo. Guárdatelo para tu cocina. Los miércoles, agua.» Caption: El grifo Miia tiene dos salidas y dos aguas: una filtrada para lavar alimentos y otra purificada y remineralizada para beber. Te contamos cuál es cuál. Ósmosis inversa en casa. Nota: pieza B2C anclada al producto (grifo de dos salidas del dossier). Distinguir «filtrada» (solo CP) de «purificada y remineralizada» (CP+RO+TC). «Miia» siempre logotipo; nunca «agua pura».",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "guardados + anclar la marca al producto",
"status": "draft",
"meta_line": "Línea: Educativo/serie (producto) · Formato: carrusel 5 slides · Objetivo: guardados + anclar la marca al producto",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-8",
"date": "2026-10-08",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Educativa",
"title": "J 8 · Stories — Dato: los 3 filtros de tu Miia (CP · RO · TC) + caja de preguntas",
"hook": "Dato: los 3 filtros de tu Miia (CP · RO · TC) + caja de preguntas",
"body": "Sticker: Sí · caja de preguntas\nNaturaleza: Educativa\nObjetivo: Preguntas recibidas en la caja",
"cta": "",
"hashtags": "",
"objective_metric": "Preguntas recibidas en la caja",
"status": "draft",
"meta_line": "Stories · Sí · caja de preguntas · Educativa",
"kind": "story",
"color": "#16204a",
"slides": []
},
{
"id": "oct26-instagram-9-2",
"date": "2026-10-09",
"time": "10:30",
"platform": "instagram",
"format": "carrusel",
"pillar": "Línea: B2B/Hostelería",
"title": "VIERNES 9 · Carrusel B2B (hostelería) — «El agua de tu restaurante también habla de ti»",
"hook": "Slide 1 · HOOK: «El café, la infusión y el agua de mesa saben a lo que sale del grifo. ¿A qué sabe el tuyo?» Slide 2: En hostelería, el agua es producto: café, coctelería, hielo, agua de mesa. La cal y el cloro se notan en la taza. Slide 3: Con una fuente Miia: agua purificada y remineralizada —fría, natural o con gas—, ilimitada. Suscripción mensual con cuota fija, sin fianza y sin permanencia. [validar oferta hostelería] Slide 4: Lo que desaparece: pedidos de garrafas, almacenaje y peso. Hay modelos de fuente para hostelería con botellas Miia incluidas para el servicio en mesa. Slide 5 · CIERRE (plantilla guardado): «¿Restaurante, bar, hotel o cafetería? Guárdate este post y escríbenos por DM: te preparamos tu propuesta.» Caption: En hostelería el agua es parte de la carta: café, coctelería y mesa. Fuentes de agua purificada y remineralizada, por suscripción, sin garrafas. Fuentes de agua para hostelería. Nota: se venden las FUENTES para hostelería (no el grifo bajo fregadero). Oferta como «cuota fija, sin fianza, sin permanencia». Requiere el destacado «Empresas» creado.",
"body": "Slide 1 · HOOK: «El café, la infusión y el agua de mesa saben a lo que sale del grifo. ¿A qué sabe el tuyo?» Slide 2: En hostelería, el agua es producto: café, coctelería, hielo, agua de mesa. La cal y el cloro se notan en la taza. Slide 3: Con una fuente Miia: agua purificada y remineralizada —fría, natural o con gas—, ilimitada. Suscripción mensual con cuota fija, sin fianza y sin permanencia. [validar oferta hostelería] Slide 4: Lo que desaparece: pedidos de garrafas, almacenaje y peso. Hay modelos de fuente para hostelería con botellas Miia incluidas para el servicio en mesa. Slide 5 · CIERRE (plantilla guardado): «¿Restaurante, bar, hotel o cafetería? Guárdate este post y escríbenos por DM: te preparamos tu propuesta.» Caption: En hostelería el agua es parte de la carta: café, coctelería y mesa. Fuentes de agua purificada y remineralizada, por suscripción, sin garrafas. Fuentes de agua para hostelería. Nota: se venden las FUENTES para hostelería (no el grifo bajo fregadero). Oferta como «cuota fija, sin fianza, sin permanencia». Requiere el destacado «Empresas» creado.",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "DMs + clic al destacado «Empresas» (decisor: restaurante, bar, hotel, cafetería)",
"status": "draft",
"meta_line": "Línea: B2B/Hostelería · Formato: carrusel 5 slides · Objetivo: DMs + clic al destacado «Empresas» (decisor: restaurante, bar, hotel, cafetería)",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-10",
"date": "2026-10-10",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "De marca",
"title": "S 10 · Stories — Lifestyle: la jarra Miia siempre llena",
"hook": "Lifestyle: la jarra Miia siempre llena",
"body": "Sticker: Sí · slider\nNaturaleza: De marca\nObjetivo: Respuestas al slider (marca)",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas al slider (marca)",
"status": "draft",
"meta_line": "Stories · Sí · slider · De marca",
"kind": "story",
"color": "#E08E2E",
"slides": []
},
{
"id": "oct26-linkedin-12",
"date": "2026-10-12",
"time": "09:00",
"platform": "linkedin",
"format": "post",
"pillar": "B2B",
"title": "LUNES 12 · B2B 2 — «Hostelería: el agua también es parte de la experiencia»",
"hook": "El café que sirves, la infusión, el hielo, el agua de mesa: todo sabe a lo que sale del grifo. La cal y el cloro se notan en la taza y en la carta.",
"body": "El café que sirves, la infusión, el hielo, el agua de mesa: todo sabe a lo que sale del grifo. La cal y el cloro se notan en la taza y en la carta.\nCada vez más restaurantes, bares y hoteles cambian las garrafas por una fuente Miia: agua purificada y remineralizada —fría, natural o con gas—, ilimitada y con cuota fija. Menos almacenaje, menos plástico en sala y una calidad constante que el cliente nota. Hay modelos de fuente con botellas Miia incluidas para el servicio en mesa.\n¿Hablamos de cómo sería en tu local?",
"cta": "¿Hablamos? Estudio de ahorro gratuito.",
"hashtags": "#AguaMiia #B2B #Oficinas #Sostenibilidad",
"objective_metric": "contactos hostelería. Enlaza con el carrusel de IG del V 9.",
"status": "draft",
"meta_line": "LinkedIn B2B · Objetivo: contactos hostelería. Enlaza con el carrusel de IG del V 9.",
"kind": "post",
"color": "#7C66B8"
},
{
"id": "oct26-instagram-story-13",
"date": "2026-10-13",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Interactiva",
"title": "M 13 · Stories — Quiz: ¿cuánta agua se pierde al purificar por ósmosis? mucha / poca / depende",
"hook": "Quiz: ¿cuánta agua se pierde al purificar por ósmosis? mucha / poca / depende",
"body": "Sticker: Sí · quiz\nNaturaleza: Interactiva\nObjetivo: Respuestas · calienta el X 14\nFrame 2 · Respuesta: depende del equipo. Los de antes desperdiciaban más; los de hoy aprovechan mucho mejor cada litro. Hoy lo explicamos.",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas · calienta el X 14",
"status": "draft",
"meta_line": "Stories · Sí · quiz · Interactiva",
"kind": "story",
"color": "#2071cf",
"slides": [
"Frame 2 · Respuesta: depende del equipo. Los de antes desperdiciaban más; los de hoy aprovechan mucho mejor cada litro. Hoy lo explicamos."
]
},
{
"id": "oct26-instagram-14-3",
"date": "2026-10-14",
"time": "10:30",
"platform": "instagram",
"format": "carrusel",
"pillar": "Línea: Educativo/serie (informativo)",
"title": "MIÉRCOLES 14 · Carrusel «Los miércoles del agua» 5 — «¿Cuánta agua se pierde al purificar por ósmosis?»",
"hook": "Slide 1 · HOOK: «Al purificar el agua por ósmosis, una parte se aparta. Te contamos por qué y cuánta, sin líos.» Slide 2: El equipo separa el agua en dos: la parte que sale limpia para beber y otra que arrastra la cal, los sedimentos y las impurezas, y que se va por el desagüe. Slide 3: Los equipos de antes desperdiciaban bastante más agua en el proceso. Los de hoy, como Miia, aprovechan mucho mejor cada litro. Slide 4 · CIERRE (plantilla guardado): «Agua purificada y remineralizada, con un proceso mucho más eficiente que antes. Guárdate este post. Los miércoles, agua.» Caption: ¿La ósmosis desperdicia agua? Una parte se aparta para arrastrar lo que se retira —y los equipos de hoy lo hacen mucho mejor que los de antes. Te lo explicamos sin líos. Ósmosis inversa en casa. Nota (cliente): informativo, no «verdad o mito». Sin tecnicismos (nada de GPD ni ratios). Usar «pierde/desperdicia», nunca «tira». Mención breve a que los equipos antiguos perdían más. Cierre en la plantilla de guardado.",
"body": "Slide 1 · HOOK: «Al purificar el agua por ósmosis, una parte se aparta. Te contamos por qué y cuánta, sin líos.» Slide 2: El equipo separa el agua en dos: la parte que sale limpia para beber y otra que arrastra la cal, los sedimentos y las impurezas, y que se va por el desagüe. Slide 3: Los equipos de antes desperdiciaban bastante más agua en el proceso. Los de hoy, como Miia, aprovechan mucho mejor cada litro. Slide 4 · CIERRE (plantilla guardado): «Agua purificada y remineralizada, con un proceso mucho más eficiente que antes. Guárdate este post. Los miércoles, agua.» Caption: ¿La ósmosis desperdicia agua? Una parte se aparta para arrastrar lo que se retira —y los equipos de hoy lo hacen mucho mejor que los de antes. Te lo explicamos sin líos. Ósmosis inversa en casa. Nota (cliente): informativo, no «verdad o mito». Sin tecnicismos (nada de GPD ni ratios). Usar «pierde/desperdicia», nunca «tira». Mención breve a que los equipos antiguos perdían más. Cierre en la plantilla de guardado.",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "resolver la objeción del «desperdicio» + guardados",
"status": "draft",
"meta_line": "Línea: Educativo/serie (informativo) · Formato: carrusel 4 slides · Objetivo: resolver la objeción del «desperdicio» + guardados",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-15",
"date": "2026-10-15",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Comunidad",
"title": "J 15 · Stories — Caja para negocios: ¿cuánto gastáis en agua al mes?",
"hook": "Caja para negocios: ¿cuánto gastáis en agua al mes?",
"body": "Sticker: Sí · caja de preguntas\nNaturaleza: Comunidad\nObjetivo: Preguntas B2B · calienta el V 9",
"cta": "",
"hashtags": "",
"objective_metric": "Preguntas B2B · calienta el V 9",
"status": "draft",
"meta_line": "Stories · Sí · caja de preguntas · Comunidad",
"kind": "story",
"color": "#5a6480",
"slides": []
},
{
"id": "oct26-instagram-16-4",
"date": "2026-10-16",
"time": "10:30",
"platform": "instagram",
"format": "imagen",
"pillar": "Línea: Prueba social",
"title": "VIERNES 16 · Imagen única prueba social — captura de una reseña real",
"hook": "Contenido de la imagen: una sola reseña real elegida, presentada como captura, con 4,7★ y el logotipo Miia. Reseña propuesta: «El café y las infusiones han mejorado una barbaridad sin la cal.» — Lucía R., Valencia. Caption: Lo que más nos dicen: el café y las infusiones cambian cuando se va la cal. Una opinión real de una clienta Miia (4,7/5 de media). Guárdate este post si estás pensándotelo. Opiniones reales de clientes. Nota (cliente): una sola reseña (no todas). Reseña real de la web; sustituir por testimonio propio del cliente cuando lo envíe. No inventar. Reseña intercambiable por otra de la lista (Marta G., Jordi P., Carlos M., Ana T., David S.). CTA de guardado en el caption.",
"body": "Contenido de la imagen: una sola reseña real elegida, presentada como captura, con 4,7★ y el logotipo Miia. Reseña propuesta: «El café y las infusiones han mejorado una barbaridad sin la cal.» — Lucía R., Valencia. Caption: Lo que más nos dicen: el café y las infusiones cambian cuando se va la cal. Una opinión real de una clienta Miia (4,7/5 de media). Guárdate este post si estás pensándotelo. Opiniones reales de clientes. Nota (cliente): una sola reseña (no todas). Reseña real de la web; sustituir por testimonio propio del cliente cuando lo envíe. No inventar. Reseña intercambiable por otra de la lista (Marta G., Jordi P., Carlos M., Ana T., David S.). CTA de guardado en el caption.",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "confianza + visitas al perfil",
"status": "draft",
"meta_line": "Línea: Prueba social · Formato: imagen única (captura/mockup de UNA reseña) · Objetivo: confianza + visitas al perfil",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-17",
"date": "2026-10-17",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Prueba social",
"title": "S 17 · Stories — Prueba social: reseña real (Marta G., Madrid) + slider",
"hook": "Prueba social: reseña real (Marta G., Madrid) + slider",
"body": "Sticker: Sí · slider\nNaturaleza: Prueba social\nObjetivo: Respuestas + confianza (reseña distinta a la del V 16)",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas + confianza (reseña distinta a la del V 16)",
"status": "draft",
"meta_line": "Stories · Sí · slider · Prueba social",
"kind": "story",
"color": "#8A93A6",
"slides": []
},
{
"id": "oct26-linkedin-19",
"date": "2026-10-19",
"time": "09:00",
"platform": "linkedin",
"format": "post",
"pillar": "B2B",
"title": "LUNES 19 · B2B 3 — «Gimnasios y wellness: hidratación ilimitada para tus socios»",
"hook": "En un gimnasio o centro wellness el agua es servicio: tus socios la esperan fría, buena y siempre disponible. Las garrafas se acaban en el peor momento y ocupan espacio útil.",
"body": "En un gimnasio o centro wellness el agua es servicio: tus socios la esperan fría, buena y siempre disponible. Las garrafas se acaban en el peor momento y ocupan espacio útil.\nUna fuente Miia de alta capacidad da agua purificada y remineralizada ilimitada, sin depender de reposiciones ni de nadie que cargue peso. Suscripción con cuota fija, mantenimiento incluido y sin permanencia. Higiene, imagen y una cosa menos que gestionar.\n¿Cuántos socios pasan al día por tu fuente de agua?",
"cta": "¿Hablamos? Estudio de ahorro gratuito.",
"hashtags": "#AguaMiia #B2B #Oficinas #Sostenibilidad",
"objective_metric": "contactos B2B (wellness y centros deportivos).",
"status": "draft",
"meta_line": "LinkedIn B2B · Objetivo: contactos B2B (wellness y centros deportivos).",
"kind": "post",
"color": "#7C66B8"
},
{
"id": "oct26-instagram-story-20",
"date": "2026-10-20",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Interactiva",
"title": "M 20 · Stories — Encuesta: ¿notas el cloro o la cal en el agua de tu casa? sí / a veces / no",
"hook": "Encuesta: ¿notas el cloro o la cal en el agua de tu casa? sí / a veces / no",
"body": "Sticker: Sí · encuesta\nNaturaleza: Interactiva\nObjetivo: Respuestas · calienta el X 21\nFrame 2 · Respuesta: ese regusto es cloro y cal. Hoy te contamos por qué el agua de Miia sabe mejor.",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas · calienta el X 21",
"status": "draft",
"meta_line": "Stories · Sí · encuesta · Interactiva",
"kind": "story",
"color": "#2071cf",
"slides": [
"Frame 2 · Respuesta: ese regusto es cloro y cal. Hoy te contamos por qué el agua de Miia sabe mejor."
]
},
{
"id": "oct26-instagram-21-5",
"date": "2026-10-21",
"time": "10:30",
"platform": "instagram",
"format": "carrusel",
"pillar": "Línea: Educativo/serie (sensorial",
"title": "MIÉRCOLES 21 · Carrusel «Los miércoles del agua» 6 — «¿Por qué el agua de Miia sabe mejor?»",
"hook": "Slide 1 · HOOK: «¿Por qué el agua de Miia sabe mejor?» Slide 2: El cloro y la cal del agua de red se notan en el sabor, en el café y en las infusiones. Miia los retira. Slide 3: Después, la remineralización le devuelve al agua el equilibrio y el sabor. Slide 4: El resultado: agua sin regusto a cloro ni a cal, ideal para beber, cocinar y para el café. Slide 5 · CIERRE (plantilla guardado): «Por eso sabe mejor. Guárdate este post y notarás la diferencia en tu próximo café. Los miércoles, agua.» Caption: ¿Por qué el agua filtrada por ósmosis sabe mejor? Menos cloro y menos cal, y una remineralización que le devuelve el sabor. Ideal para tu café y tus infusiones. Agua remineralizada en casa. Nota: pieza sensorial y relatable (conecta con las reseñas sobre café/infusiones). Sin tecnicismos. Nunca «agua pura». «Miia» siempre logotipo. Cierre en la plantilla de guardado.",
"body": "Slide 1 · HOOK: «¿Por qué el agua de Miia sabe mejor?» Slide 2: El cloro y la cal del agua de red se notan en el sabor, en el café y en las infusiones. Miia los retira. Slide 3: Después, la remineralización le devuelve al agua el equilibrio y el sabor. Slide 4: El resultado: agua sin regusto a cloro ni a cal, ideal para beber, cocinar y para el café. Slide 5 · CIERRE (plantilla guardado): «Por eso sabe mejor. Guárdate este post y notarás la diferencia en tu próximo café. Los miércoles, agua.» Caption: ¿Por qué el agua filtrada por ósmosis sabe mejor? Menos cloro y menos cal, y una remineralización que le devuelve el sabor. Ideal para tu café y tus infusiones. Agua remineralizada en casa. Nota: pieza sensorial y relatable (conecta con las reseñas sobre café/infusiones). Sin tecnicismos. Nunca «agua pura». «Miia» siempre logotipo. Cierre en la plantilla de guardado.",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "guardados + notoriedad (pieza nueva; la de personalización TC ya se hizo en septiembre)",
"status": "draft",
"meta_line": "Línea: Educativo/serie (sensorial · producto) · Formato: carrusel 5 slides · Objetivo: guardados + notoriedad (pieza nueva; la de personalización TC ya se hizo en septiembre)",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-22",
"date": "2026-10-22",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Educativa",
"title": "J 22 · Stories — Caja: ¿dónde notas más tu agua — café, piel, electrodomésticos?",
"hook": "Caja: ¿dónde notas más tu agua — café, piel, electrodomésticos?",
"body": "Sticker: Sí · caja de preguntas\nNaturaleza: Educativa\nObjetivo: Experiencias que alimentan la conversación del X 21",
"cta": "",
"hashtags": "",
"objective_metric": "Experiencias que alimentan la conversación del X 21",
"status": "draft",
"meta_line": "Stories · Sí · caja de preguntas · Educativa",
"kind": "story",
"color": "#16204a",
"slides": []
},
{
"id": "oct26-instagram-23-6",
"date": "2026-10-23",
"time": "10:30",
"platform": "instagram",
"format": "Reel/vídeo",
"pillar": "Línea: Familia/bienestar",
"title": "VIERNES 23 · Vídeo/Reel familia — «El agua de casa, sin pensarlo»",
"hook": "Escena 1 (0–3 s): Vídeo: jarra Miia llenándose en el grifo de la cocina, luz de mañana. Texto en pantalla: «Agua de calidad, sin salir de casa.» Escena 2 (3–8 s): Vídeo: vaso servido en la mesa del desayuno. Texto: «Purificada y remineralizada, directa de tu grifo.» Escena 3 (8–12 s): Texto: «Sin plástico. Sin garrafas. Sin cargar peso.» Escena 4 (12–15 s): Cierre con el logotipo Miia + «Agua ilimitada por suscripción. Puede ahorrarte más de lo que crees.» Audio: música de biblioteca tranquila, sin locución. Máximo 2 cortes de vídeo. Caption: Agua ilimitada en casa, pagando siempre lo mismo. Purificada y remineralizada, sin plástico y sin cargar peso desde el súper. Alternativa a las botellas y garrafas de agua. Nota de producción: es el vídeo del mes (segunda mitad). Priorizar metraje IA que muestre el producto Miia (grifo/torreta) para anclar la marca; el stock, como apoyo. No sobreproducir. «Miia» siempre logotipo; nada de tipografía en amarillo; nunca «agua pura».",
"body": "Escena 1 (0–3 s): Vídeo: jarra Miia llenándose en el grifo de la cocina, luz de mañana. Texto en pantalla: «Agua de calidad, sin salir de casa.» Escena 2 (3–8 s): Vídeo: vaso servido en la mesa del desayuno. Texto: «Purificada y remineralizada, directa de tu grifo.» Escena 3 (8–12 s): Texto: «Sin plástico. Sin garrafas. Sin cargar peso.» Escena 4 (12–15 s): Cierre con el logotipo Miia + «Agua ilimitada por suscripción. Puede ahorrarte más de lo que crees.» Audio: música de biblioteca tranquila, sin locución. Máximo 2 cortes de vídeo. Caption: Agua ilimitada en casa, pagando siempre lo mismo. Purificada y remineralizada, sin plástico y sin cargar peso desde el súper. Alternativa a las botellas y garrafas de agua. Nota de producción: es el vídeo del mes (segunda mitad). Priorizar metraje IA que muestre el producto Miia (grifo/torreta) para anclar la marca; el stock, como apoyo. No sobreproducir. «Miia» siempre logotipo; nada de tipografía en amarillo; nunca «agua pura».",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "alcance + nuevos seguidores",
"status": "draft",
"meta_line": "Línea: Familia/bienestar · Formato: Reel/vídeo 10–15 s (metraje IA del producto o stock) · Objetivo: alcance + nuevos seguidores",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-24",
"date": "2026-10-24",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Interactiva",
"title": "S 24 · Stories — Slider: ¿cómo puntuarías Agua Miia?",
"hook": "Slider: ¿cómo puntuarías Agua Miia?",
"body": "Sticker: Sí · slider\nNaturaleza: Interactiva\nObjetivo: Respuestas al slider + notoriedad de marca",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas al slider + notoriedad de marca",
"status": "draft",
"meta_line": "Stories · Sí · slider · Interactiva",
"kind": "story",
"color": "#2071cf",
"slides": []
},
{
"id": "oct26-linkedin-26",
"date": "2026-10-26",
"time": "09:00",
"platform": "linkedin",
"format": "post",
"pillar": "B2B",
"title": "LUNES 26 · B2B 4 (cierre de mes) — «Sin permanencia y sin fianza: por qué la suscripción encaja en empresas»",
"hook": "Cambiar el modelo del agua en una empresa suele frenarse por el mismo miedo: ataduras. Con Miia no las hay: suscripción mensual con cuota fija, sin fianza y sin permanencia, con in",
"body": "Cambiar el modelo del agua en una empresa suele frenarse por el mismo miedo: ataduras. Con Miia no las hay: suscripción mensual con cuota fija, sin fianza y sin permanencia, con instalación y mantenimiento incluidos.\nSe escala por sedes, se ajusta al consumo y se cancela si deja de encajar. El coste deja de ser una variable (pedidos, garrafas, imprevistos) y pasa a ser una cuota previsible.\nCerramos octubre con una invitación: te preparamos un estudio de ahorro gratuito para tu caso. Escríbenos.",
"cta": "¿Hablamos? Estudio de ahorro gratuito.",
"hashtags": "#AguaMiia #B2B #Oficinas #Sostenibilidad",
"objective_metric": "contactos B2B + cierre de mes.",
"status": "draft",
"meta_line": "LinkedIn B2B · Objetivo: contactos B2B + cierre de mes.",
"kind": "post",
"color": "#7C66B8"
},
{
"id": "oct26-instagram-story-27",
"date": "2026-10-27",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Interactiva",
"title": "M 27 · Stories — Quiz: ¿tu equipo Miia necesita depósito? sí / no",
"hook": "Quiz: ¿tu equipo Miia necesita depósito? sí / no",
"body": "Sticker: Sí · quiz\nNaturaleza: Interactiva\nObjetivo: Respuestas · calienta el X 28\nFrame 2 · Respuesta: no. Miia es de flujo directo, sin depósito: el agua sale recién hecha. Hoy en el post.",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas · calienta el X 28",
"status": "draft",
"meta_line": "Stories · Sí · quiz · Interactiva",
"kind": "story",
"color": "#2071cf",
"slides": [
"Frame 2 · Respuesta: no. Miia es de flujo directo, sin depósito: el agua sale recién hecha. Hoy en el post."
]
},
{
"id": "oct26-instagram-28-7",
"date": "2026-10-28",
"time": "10:30",
"platform": "instagram",
"format": "carrusel",
"pillar": "Línea: Educativo/serie (producto)",
"title": "MIÉRCOLES 28 · Carrusel «Los miércoles del agua» 7 — «¿Por qué tu Miia no necesita depósito?»",
"hook": "Slide 1 · HOOK: «Muchos equipos de ósmosis guardan el agua en un depósito. El de Miia, no. ¿Por qué importa?» Slide 2: Los sistemas con depósito acumulan agua ya tratada durante horas, esperando a que abras el grifo. Slide 3: Miia es de flujo directo: purifica y remineraliza el agua en el momento, sin depósito. [foto de la torreta compacta bajo el fregadero] Sale recién hecha. Slide 4: Ventajas: agua siempre fresca y un equipo compacto que cabe bajo el fregadero. Sin agua estancada esperando. Slide 5 · CIERRE (plantilla guardado): «Agua purificada y remineralizada, recién hecha cada vez que abres el grifo. Guárdate este post. Los miércoles, agua.» Caption: ¿Depósito o flujo directo? Miia purifica y remineraliza el agua en el momento, sin acumularla: más fresca y más compacta bajo tu fregadero. Ósmosis de flujo directo en casa. Nota: producto-anclado (flujo directo, sin depósito, compacto). Sin cifras técnicas (fuera «800 GPD»). Foto del producto. «Miia» logotipo. Cierre en la plantilla de guardado.",
"body": "Slide 1 · HOOK: «Muchos equipos de ósmosis guardan el agua en un depósito. El de Miia, no. ¿Por qué importa?» Slide 2: Los sistemas con depósito acumulan agua ya tratada durante horas, esperando a que abras el grifo. Slide 3: Miia es de flujo directo: purifica y remineraliza el agua en el momento, sin depósito. [foto de la torreta compacta bajo el fregadero] Sale recién hecha. Slide 4: Ventajas: agua siempre fresca y un equipo compacto que cabe bajo el fregadero. Sin agua estancada esperando. Slide 5 · CIERRE (plantilla guardado): «Agua purificada y remineralizada, recién hecha cada vez que abres el grifo. Guárdate este post. Los miércoles, agua.» Caption: ¿Depósito o flujo directo? Miia purifica y remineraliza el agua en el momento, sin acumularla: más fresca y más compacta bajo tu fregadero. Ósmosis de flujo directo en casa. Nota: producto-anclado (flujo directo, sin depósito, compacto). Sin cifras técnicas (fuera «800 GPD»). Foto del producto. «Miia» logotipo. Cierre en la plantilla de guardado.",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "guardados + notoriedad de producto",
"status": "draft",
"meta_line": "Línea: Educativo/serie (producto) · Formato: carrusel 5 slides · Objetivo: guardados + notoriedad de producto",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-29",
"date": "2026-10-29",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "Comunidad",
"title": "J 29 · Stories — Caja: ¿qué es lo que más pereza te da del agua embotellada?",
"hook": "Caja: ¿qué es lo que más pereza te da del agua embotellada?",
"body": "Sticker: Sí · caja de preguntas\nNaturaleza: Comunidad\nObjetivo: Objeciones reales · material para futuros posts",
"cta": "",
"hashtags": "",
"objective_metric": "Objeciones reales · material para futuros posts",
"status": "draft",
"meta_line": "Stories · Sí · caja de preguntas · Comunidad",
"kind": "story",
"color": "#5a6480",
"slides": []
},
{
"id": "oct26-instagram-30-8",
"date": "2026-10-30",
"time": "10:30",
"platform": "instagram",
"format": "carrusel",
"pillar": "Línea: Familia/bienestar (comodidad)",
"title": "VIERNES 30 · Carrusel familia/comodidad — «La vida en casa cuando el agua deja de ser un problema»",
"hook": "Slide 1 · HOOK: «¿Cuánto tiempo pasas comprando, cargando y guardando agua? Imagina no volver a hacerlo.» Slide 2: Sin torres de botellas en la despensa. Sin bajar garrafas. Sin quedarte sin agua un domingo. Slide 3: Agua purificada y remineralizada, ilimitada, en tu grifo. Para beber, cocinar, el café y las infusiones —sin cal. Slide 4: Una cuota fija al mes, sin permanencia. Del mantenimiento y los filtros nos ocupamos nosotros. Slide 5 · CIERRE (plantilla guardado): «Guárdate este post si estás pensando en dejar atrás las botellas. Y escríbenos por DM: te ayudamos.» Caption: Cómo cambia el día a día cuando el agua buena está siempre en tu grifo: sin cargar, sin almacenar, sin cal. Agua purificada y remineralizada por suscripción. Comodidad en casa. Nota: cierre de mes cálido (familia/comodidad), no repite las cuentas de ahorro de septiembre. Nunca «agua pura». Cierre en la plantilla de guardado.",
"body": "Slide 1 · HOOK: «¿Cuánto tiempo pasas comprando, cargando y guardando agua? Imagina no volver a hacerlo.» Slide 2: Sin torres de botellas en la despensa. Sin bajar garrafas. Sin quedarte sin agua un domingo. Slide 3: Agua purificada y remineralizada, ilimitada, en tu grifo. Para beber, cocinar, el café y las infusiones —sin cal. Slide 4: Una cuota fija al mes, sin permanencia. Del mantenimiento y los filtros nos ocupamos nosotros. Slide 5 · CIERRE (plantilla guardado): «Guárdate este post si estás pensando en dejar atrás las botellas. Y escríbenos por DM: te ayudamos.» Caption: Cómo cambia el día a día cuando el agua buena está siempre en tu grifo: sin cargar, sin almacenar, sin cal. Agua purificada y remineralizada por suscripción. Comodidad en casa. Nota: cierre de mes cálido (familia/comodidad), no repite las cuentas de ahorro de septiembre. Nunca «agua pura». Cierre en la plantilla de guardado.",
"cta": "Más info / DM",
"hashtags": "#AguaMiia #AguaDeCalidad #Osmosis",
"objective_metric": "guardados + DMs (pieza nueva; el ahorro «cuenta anual» ya se hizo en septiembre)",
"status": "draft",
"meta_line": "Línea: Familia/bienestar (comodidad) · Formato: carrusel 5 slides · Objetivo: guardados + DMs (pieza nueva; el ahorro «cuenta anual» ya se hizo en septiembre)",
"kind": "feed",
"slides": []
},
{
"id": "oct26-instagram-story-31",
"date": "2026-10-31",
"time": "09:00",
"platform": "instagram",
"format": "Stories",
"pillar": "De marca",
"title": "S 31 · Stories — Lifestyle: el finde también sabe mejor sin cal",
"hook": "Lifestyle: el finde también sabe mejor sin cal",
"body": "Sticker: Sí · slider\nNaturaleza: De marca\nObjetivo: Respuestas al slider + notoriedad",
"cta": "",
"hashtags": "",
"objective_metric": "Respuestas al slider + notoriedad",
"status": "draft",
"meta_line": "Stories · Sí · slider · De marca",
"kind": "story",
"color": "#E08E2E",
"slides": []
}
]
}

153
src/lib/ad-tracker-range.ts Normal file
View File

@@ -0,0 +1,153 @@
/** Date presets for Ad Tracker (Brava-style). No date-fns dependency. */
export type RangePreset =
| 'today'
| 'yesterday'
| 'last_7d'
| 'last_14d'
| 'last_28d'
| 'last_30d'
| 'this_week'
| 'last_week'
| 'this_month'
| 'last_month'
| 'custom';
export type DateRange = { since: string; until: string; label: string };
const QUICK: { id: RangePreset; label: string }[] = [
{ id: 'today', label: 'Hoy' },
{ id: 'yesterday', label: 'Ayer' },
{ id: 'last_7d', label: '7 días' },
{ id: 'last_14d', label: '14 días' },
{ id: 'last_28d', label: '28 días' },
{ id: 'last_30d', label: '30 días' },
{ id: 'this_week', label: 'Esta semana' },
{ id: 'last_week', label: 'Semana pasada' },
{ id: 'this_month', label: 'Este mes' },
{ id: 'last_month', label: 'Mes pasado' },
];
export { QUICK as AD_TRACKER_PRESETS };
function iso(d: Date): string {
return d.toISOString().slice(0, 10);
}
function startOfWeekMon(d: Date): Date {
const x = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const day = x.getUTCDay();
const diff = day === 0 ? -6 : 1 - day;
x.setUTCDate(x.getUTCDate() + diff);
return x;
}
const MONTHS_ES = [
'ene',
'feb',
'mar',
'abr',
'may',
'jun',
'jul',
'ago',
'sep',
'oct',
'nov',
'dic',
];
export function formatRangeLabel(since: string, until: string): string {
const a = since.split('-').map(Number);
const b = until.split('-').map(Number);
if (a.length !== 3 || b.length !== 3) return `${since} - ${until}`;
return `${a[2]} ${MONTHS_ES[a[1] - 1]} ${a[0]} - ${b[2]} ${MONTHS_ES[b[1] - 1]} ${b[0]}`;
}
export function resolveAdRange(
preset: RangePreset,
custom?: { since?: string; until?: string }
): DateRange {
const now = new Date();
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
);
let since = today;
let until = today;
switch (preset) {
case 'today':
break;
case 'yesterday':
since = new Date(today);
since.setUTCDate(since.getUTCDate() - 1);
until = new Date(since);
break;
case 'last_7d':
since = new Date(today);
since.setUTCDate(since.getUTCDate() - 6);
break;
case 'last_14d':
since = new Date(today);
since.setUTCDate(since.getUTCDate() - 13);
break;
case 'last_28d':
since = new Date(today);
since.setUTCDate(since.getUTCDate() - 27);
break;
case 'last_30d':
since = new Date(today);
since.setUTCDate(since.getUTCDate() - 29);
break;
case 'this_week':
since = startOfWeekMon(today);
until = today;
break;
case 'last_week': {
const thisMon = startOfWeekMon(today);
until = new Date(thisMon);
until.setUTCDate(until.getUTCDate() - 1);
since = startOfWeekMon(until);
break;
}
case 'this_month':
since = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1));
until = today;
break;
case 'last_month': {
const firstThis = new Date(
Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)
);
until = new Date(firstThis);
until.setUTCDate(until.getUTCDate() - 1);
since = new Date(Date.UTC(until.getUTCFullYear(), until.getUTCMonth(), 1));
break;
}
case 'custom':
since = custom?.since ? new Date(custom.since + 'T00:00:00Z') : since;
until = custom?.until ? new Date(custom.until + 'T00:00:00Z') : until;
break;
}
const s = iso(since);
const u = iso(until);
return { since: s, until: u, label: formatRangeLabel(s, u) };
}
export function normalizePlatform(p?: string): 'meta' | 'google' | 'other' {
const x = (p || '').toLowerCase();
if (
x.includes('meta') ||
x.includes('facebook') ||
x.includes('instagram') ||
x === 'fb' ||
x === 'ig'
) {
return 'meta';
}
if (x.includes('google') || x.includes('adwords') || x === 'gads') {
return 'google';
}
return 'other';
}

View File

@@ -18,6 +18,12 @@ export type AdDailyRow = {
platform_leads?: number;
impressions?: number;
clicks?: number;
platform?: string;
campaign_name?: string;
campaign?: string;
ad_name?: string;
conversions?: number;
tenant_id?: string;
};
export type MetricTotals = {