diff --git a/src/app/api/calendar/publish/route.ts b/src/app/api/calendar/publish/route.ts new file mode 100644 index 0000000..e8afe47 --- /dev/null +++ b/src/app/api/calendar/publish/route.ts @@ -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>( + `/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, + }); +} diff --git a/src/app/dashboard/ad-tracker/page.tsx b/src/app/dashboard/ad-tracker/page.tsx new file mode 100644 index 0000000..f8a73b1 --- /dev/null +++ b/src/app/dashboard/ad-tracker/page.tsx @@ -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 = { + '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 ( +

+ Selecciona un cliente en Contexto para abrir su Ad Tracker (privado por + tenant). +

+ ); + } + + let daily: AdDailyRow[] = []; + if (agency && ctx.viaView && ctx.tenantId) { + const tid = encodeURIComponent(ctx.tenantId); + const d = await restGet( + `/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 ( + + ); +} diff --git a/src/app/dashboard/calendario/PublishRrssButton.tsx b/src/app/dashboard/calendario/PublishRrssButton.tsx new file mode 100644 index 0000000..3d0bfe3 --- /dev/null +++ b/src/app/dashboard/calendario/PublishRrssButton.tsx @@ -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(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 ( +
+ + {msg ?

{msg}

: null} +
+ ); +} diff --git a/src/app/dashboard/calendario/page.tsx b/src/app/dashboard/calendario/page.tsx index 2d3ed08..1c1461b 100644 --- a/src/app/dashboard/calendario/page.tsx +++ b/src/app/dashboard/calendario/page.tsx @@ -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; + 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 ( +

+ Selecciona un cliente en Contexto para ver su hub RRSS (privado). +

+ ); + } + + let calendarDocs: Array<{ title?: string; content?: unknown }> = []; + if (agency && ctx.viaView && ctx.tenantId) { const tid = encodeURIComponent(ctx.tenantId); const r = await restGet( `/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 ( +
+
+

+ RRSS · privado +

+

+ {ctx.label} +

+

+ Calendario Instagram/Facebook y estrategia LinkedIn. Sin publicación + pública: solo portal + doble check agencia/cliente. +

+
+
+ + IG / FB + + + LinkedIn + + {agency ? : null} +
+
+ ); + } + + // Archive of months (like /rrss/ig-fb/) + if (view === 'archive') { + return ( +
+
+
+ + ← Hub RRSS + +

+ Calendario IG / FB +

+

+ Archivo mensual renovable · {ctx.label} +

+
+
+
+ {plans.map((p) => ( + +
+ + {p.label} + + + Live + +
+
+

+ {(p.pieces || []).length} piezas · clic para abrir el mes + completo (reparto + calendario + copy) +

+
+ + ))} +
+
+ ); + } + + // Month plan (like /rrss/ig-fb/octubre-2026/) + if (active && active.kind === 'rrss_month') { + return ( + + ); + } + + // Legacy flat posts fallback + const legacy = asLegacyPosts( + typeof calendarDocs[0]?.content === 'string' + ? JSON.parse(calendarDocs[0].content as string) + : calendarDocs[0]?.content + ); + return ( -
+
- RRSS · plan editorial + RRSS
-

+

Calendario editorial — {ctx.label}

-

- 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. -

- - {ctx.portfolioWide ? ( -

- Selecciona un cliente en Contexto para ver su calendario editorial. -

- ) : ( - - )} +
); } diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 88a9a2a..2f09cfa 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -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({ Calendario Editorial + + + Ad Tracker + ): 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(); + 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(); + 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 ( +

+ Sin serie diaria para este rango +

+ ); + } + + 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 ( +
+
+ {values.map((v, i) => ( +
+
+ +
+
+ ))} +
+
+ {data[0]?.date.slice(5)} + {data[data.length - 1]?.date.slice(5)} +
+
+ ); +} + +function KpiTile({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function CampaignTable({ + title, + rows, + empty, +}: { + title: string; + rows: CampaignRow[]; + empty: string; +}) { + return ( +
+
+

+ Campañas activas +

+

{title}

+
+ {rows.length === 0 ? ( +

{empty}

+ ) : ( +
+ + + + + + + + + + + + {rows.slice(0, 25).map((r) => ( + + + + + + + + ))} + +
CampañaGastoClicsConv.CPL
+ {r.name} + + {formatEur(r.spend)} + + {formatNum(r.clicks)} + + {formatNum(r.conversions)} + + {r.cpl != null ? formatEur(r.cpl) : '—'} +
+
+ )} +
+ ); +} + +export default function AdTrackerClient({ + label, + tagline, + rows, +}: { + label: string; + tagline?: string; + rows: AdDailyRow[]; +}) { + const [preset, setPreset] = useState('last_7d'); + const [tab, setTab] = useState('resumen'); + const [chartMode, setChartMode] = useState('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 ( +
+
+
+

+ Ad tracker · {label} +

+

+ {tagline || 'Meta + Google Ads'} +

+

+ Resumen agregado de Meta y Google Ads: gasto, clics, conversiones y + campañas activas en el periodo. +

+
+
+ + {/* Period presets */} +
+

+ Periodo +

+
+ {AD_TRACKER_PRESETS.map((p) => ( + + ))} + + {range.label} + +
+
+ + {/* Tabs */} +
+ {( + [ + { id: 'resumen', label: 'Resumen' }, + { id: 'meta', label: 'Meta Ads' }, + { id: 'google', label: 'Google Ads' }, + ] as const + ).map((t) => ( + + ))} +
+ + {tab === 'resumen' && ( +
+
+
+

+ Resumen de cuenta +

+

+ Meta + Google, juntos +

+

+ Gasto, impresiones, clics y conversiones sumados. CTR, CPC y CPL + se recalculan sobre el total (no se suman). Ventana:{' '} + {range.since} → {range.until}. +

+
+ +
+ + + + + + + + +
+ +
+
+
+
+ +
+ + +
+
+ +
+
+
+

+ Rendimiento diario +

+

+ Conversiones y CPL +

+

+ Barras: coste por lead. Línea: conversiones. CPL{' '} + {combined.cpl != null ? formatEur(combined.cpl) : '—'} +

+
+
+ {chips.map((c) => ( + + ))} +
+
+ +
+ +
+
+
+

+ Rendimiento diario +

+

Coste y clics

+

+ Barras: coste. Línea: clics. {formatEur(combined.spend)} ·{' '} + {formatNum(combined.clicks)} clics +

+
+
+ {chips.map((c) => ( + + ))} +
+
+ +
+ +
+ + +
+
+ )} + + {tab === 'meta' && ( +
+
+ + + + +
+
+

+ Conversiones y CPL · Meta +

+ +
+
+

+ Coste y clics · Meta +

+ +
+ +
+ )} + + {tab === 'google' && ( +
+
+ + + + +
+
+

+ Conversiones y CPL · Google +

+ +
+
+

+ Coste y clics · Google +

+ +
+ +
+ )} +
+ ); +} diff --git a/src/components/RrssMonthView.tsx b/src/components/RrssMonthView.tsx new file mode 100644 index 0000000..29f5aa6 --- /dev/null +++ b/src/components/RrssMonthView.tsx @@ -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 = { + 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 ( + + Pendiente + + ); + } + if (dual.both) { + return ( + + Doble OK + + ); + } + return ( + + {dual.label} + + ); +} + +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(null); + const [dualMap, setDualMap] = useState>({}); + const [comments, setComments] = useState([]); + 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(); + 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 ( +
+ {/* Hub crumbs */} +
+
+ + RRSS + + / + + Archivo + + / + {plan.label} +
+ + {tenantLabel} · privado · doble check + +
+ + {/* Hero */} +
+

+ {plan.hero.eyebrow || '4VENTURES · PLAN OPERATIVO'} +

+

+ {plan.hero.title} +

+

+ {plan.hero.lede} +

+
+ {(plan.hero.chips || []).map((c) => ( + + {c} + + ))} +
+
+ + {/* Network tabs */} +
+ {platforms.map((p) => ( + + ))} +
+ + {/* Reparto posts */} + {(plan.reparto_posts || []).length > 0 && ( +
+

+ 1 · Reparto de los posts del mes +

+

+ Porcentaje del mes · objetivo de métrica a la derecha +

+
+ {(plan.reparto_posts || []).map((r) => ( +
+ + {r.pct}% + +
+

{r.label}

+

{r.detail}

+
+
+
+
+

+ Objetivo: {r.objective} +

+
+ ))} +
+
+ )} + + {(plan.reparto_stories || []).length > 0 && ( +
+

+ 2 · Reparto de las Stories del mes +

+
+ {(plan.reparto_stories || []).map((r) => ( +
+ + {r.pct}% + +
+

{r.label}

+

{r.detail}

+
+
+
+
+

+ Objetivo: {r.objective} +

+
+ ))} +
+
+ )} + + {/* Legend */} + {(plan.legend || []).length > 0 && ( +
+ {(plan.legend || []).map((l) => ( + + {l.label} + + ))} +
+ )} + + {/* Month grid */} +
+

+ El mes de un vistazo +

+

+ Cada tarjeta de color es una publicación; clic para ver copy, aprobar y + comentar. +

+
+ {['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((d) => ( +
+ {d} +
+ ))} +
+
+ {Array.from({ length: startPad }).map((_, i) => ( +
+ ))} + {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 ( +
+

{day}

+
+ {dayPieces.slice(0, 3).map((p) => ( + + ))} + {dayPieces.length > 3 && ( +

+ +{dayPieces.length - 3} +

+ )} +
+
+ ); + })} +
+
+ + {/* Piece by piece */} +
+

+ Contenido completo, pieza a pieza +

+ {filtered.map((p) => ( + + ))} +
+ + {selected && ( +
+
+
+
+

{selected.title}

+

+ {selected.platform} · {selected.date} · {selected.format} +

+
+ +
+
+ + {selected.meta_line && ( +

{selected.meta_line}

+ )} +
+ {selected.hook} +
+
+ {selected.body} +
+ {selected.cta && ( +

👉 {selected.cta}

+ )} + {selected.hashtags && ( +

{selected.hashtags}

+ )} + + {canCollaborate && ( +
+

+ {' '} + Comentarios +

+
    + {comments.map((c) => ( +
  • +

    + {c.author_party} · {c.author_email} +

    +

    + {c.body} +

    +
  • + ))} +
+
+ 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…" + /> + +
+
+ )} +
+
+ + {canCollaborate && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/src/data/rrss-octubre-2026.json b/src/data/rrss-octubre-2026.json new file mode 100644 index 0000000..943bff1 --- /dev/null +++ b/src/data/rrss-octubre-2026.json @@ -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": [] + } + ] +} diff --git a/src/lib/ad-tracker-range.ts b/src/lib/ad-tracker-range.ts new file mode 100644 index 0000000..090719b --- /dev/null +++ b/src/lib/ad-tracker-range.ts @@ -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'; +} diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 5f2d74e..4f68120 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -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 = {