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:
663
src/components/AdTrackerClient.tsx
Normal file
663
src/components/AdTrackerClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
630
src/components/RrssMonthView.tsx
Normal file
630
src/components/RrssMonthView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user