feat(kpis): agregar seccion Windsor.ai de blueparrotschool.com debajo de Cruce por canal
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
|||||||
sumWeekly,
|
sumWeekly,
|
||||||
} from '@/lib/metrics';
|
} from '@/lib/metrics';
|
||||||
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency';
|
||||||
|
import { OrganicDailyRow } from '@/lib/organic';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -256,6 +257,70 @@ async function enrichDealSources(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paginate organic & web daily metrics (GSC + GA4) for active tenant / portfolio. */
|
||||||
|
async function fetchOrganicDaily(
|
||||||
|
tenantId: string | null,
|
||||||
|
useServiceRole: boolean
|
||||||
|
): Promise<OrganicDailyRow[]> {
|
||||||
|
const since = new Date();
|
||||||
|
since.setUTCDate(since.getUTCDate() - AD_DAILY_LOOKBACK_DAYS);
|
||||||
|
const sinceStr = since.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (useServiceRole) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return [];
|
||||||
|
const tidFilter = tenantId
|
||||||
|
? `&tenant_id=eq.${encodeURIComponent(tenantId)}`
|
||||||
|
: '';
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/organic_daily?select=*&day=gte.${sinceStr}${tidFilter}&order=day.desc&limit=1000`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return (await r.json()) as OrganicDailyRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
let query = supabase
|
||||||
|
.from('organic_daily')
|
||||||
|
.select('*')
|
||||||
|
.gte('day', sinceStr)
|
||||||
|
.order('day', { ascending: false })
|
||||||
|
.limit(1000);
|
||||||
|
if (tenantId) {
|
||||||
|
query = query.eq('tenant_id', tenantId);
|
||||||
|
}
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) return [];
|
||||||
|
return (data as OrganicDailyRow[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active integrations for connector status pills. */
|
||||||
|
async function fetchIntegrations(
|
||||||
|
tenantId: string | null,
|
||||||
|
useServiceRole: boolean
|
||||||
|
) {
|
||||||
|
if (!tenantId) return [];
|
||||||
|
if (useServiceRole) {
|
||||||
|
const headers = serviceHeaders();
|
||||||
|
const base = supabaseUrl();
|
||||||
|
if (!headers || !base) return [];
|
||||||
|
const r = await fetch(
|
||||||
|
`${base}/rest/v1/integrations?tenant_id=eq.${encodeURIComponent(tenantId)}&select=provider,status,external_account_id,last_sync_at,label`,
|
||||||
|
{ headers, cache: 'no-store' }
|
||||||
|
);
|
||||||
|
if (!r.ok) return [];
|
||||||
|
return await r.json();
|
||||||
|
}
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('integrations')
|
||||||
|
.select('provider,status,external_account_id,last_sync_at,label')
|
||||||
|
.eq('tenant_id', tenantId);
|
||||||
|
return data || [];
|
||||||
|
}
|
||||||
|
|
||||||
export default async function KpisPage() {
|
export default async function KpisPage() {
|
||||||
const supabase = await createClient();
|
const supabase = await createClient();
|
||||||
const {
|
const {
|
||||||
@@ -270,6 +335,8 @@ export default async function KpisPage() {
|
|||||||
let daily: AdDailyRow[] = [];
|
let daily: AdDailyRow[] = [];
|
||||||
let crmLeads: CrmLeadRow[] = [];
|
let crmLeads: CrmLeadRow[] = [];
|
||||||
let wonDeals: DealWonRow[] = [];
|
let wonDeals: DealWonRow[] = [];
|
||||||
|
let organic: OrganicDailyRow[] = [];
|
||||||
|
let integrations: any[] = [];
|
||||||
|
|
||||||
if (agency && (ctx.portfolioWide || ctx.viaView)) {
|
if (agency && (ctx.portfolioWide || ctx.viaView)) {
|
||||||
if (ctx.portfolioWide) {
|
if (ctx.portfolioWide) {
|
||||||
@@ -280,6 +347,8 @@ export default async function KpisPage() {
|
|||||||
daily = await fetchAdDaily(null, true);
|
daily = await fetchAdDaily(null, true);
|
||||||
crmLeads = [];
|
crmLeads = [];
|
||||||
wonDeals = await fetchWonDeals(null, true);
|
wonDeals = await fetchWonDeals(null, true);
|
||||||
|
organic = await fetchOrganicDaily(null, true);
|
||||||
|
integrations = [];
|
||||||
} else if (ctx.tenantId) {
|
} else if (ctx.tenantId) {
|
||||||
const tid = encodeURIComponent(ctx.tenantId);
|
const tid = encodeURIComponent(ctx.tenantId);
|
||||||
const w = await restGet<KpiWeeklyRow[]>(
|
const w = await restGet<KpiWeeklyRow[]>(
|
||||||
@@ -289,6 +358,8 @@ export default async function KpisPage() {
|
|||||||
daily = await fetchAdDaily(ctx.tenantId, true);
|
daily = await fetchAdDaily(ctx.tenantId, true);
|
||||||
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
crmLeads = await fetchCrmLeads(ctx.tenantId, true);
|
||||||
wonDeals = await fetchWonDeals(ctx.tenantId, true);
|
wonDeals = await fetchWonDeals(ctx.tenantId, true);
|
||||||
|
organic = await fetchOrganicDaily(ctx.tenantId, true);
|
||||||
|
integrations = await fetchIntegrations(ctx.tenantId, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const { data: w } = await supabase
|
const { data: w } = await supabase
|
||||||
@@ -299,6 +370,8 @@ export default async function KpisPage() {
|
|||||||
daily = await fetchAdDaily(ctx.tenantId, false);
|
daily = await fetchAdDaily(ctx.tenantId, false);
|
||||||
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
crmLeads = await fetchCrmLeads(ctx.tenantId, false);
|
||||||
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
wonDeals = await fetchWonDeals(ctx.tenantId, false);
|
||||||
|
organic = await fetchOrganicDaily(ctx.tenantId, false);
|
||||||
|
integrations = await fetchIntegrations(ctx.tenantId, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cohort = resolveWonCohort(wonDeals, crmLeads);
|
const cohort = resolveWonCohort(wonDeals, crmLeads);
|
||||||
@@ -319,6 +392,7 @@ export default async function KpisPage() {
|
|||||||
<MetricsDashboard
|
<MetricsDashboard
|
||||||
label={ctx.label}
|
label={ctx.label}
|
||||||
portfolioWide={ctx.portfolioWide}
|
portfolioWide={ctx.portfolioWide}
|
||||||
|
tenantId={ctx.tenantId}
|
||||||
month={month}
|
month={month}
|
||||||
week={comparisons.currentWeek}
|
week={comparisons.currentWeek}
|
||||||
deltas={comparisons.deltas}
|
deltas={comparisons.deltas}
|
||||||
@@ -327,8 +401,10 @@ export default async function KpisPage() {
|
|||||||
weeklyRows={weekly}
|
weeklyRows={weekly}
|
||||||
channelPoints={channelSeries.points}
|
channelPoints={channelSeries.points}
|
||||||
dailyRows={daily}
|
dailyRows={daily}
|
||||||
|
organicRows={organic}
|
||||||
crmLeads={crmLeads}
|
crmLeads={crmLeads}
|
||||||
wonDeals={wonDeals}
|
wonDeals={wonDeals}
|
||||||
|
integrations={integrations}
|
||||||
cohortSource={cohort.source}
|
cohortSource={cohort.source}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
import { PageHeader } from '@/components/ui';
|
import { PageHeader } from '@/components/ui';
|
||||||
import SyncNowButton from '@/components/SyncNowButton';
|
import SyncNowButton from '@/components/SyncNowButton';
|
||||||
import { useDateRange } from '@/components/DateRangeContext';
|
import { useDateRange } from '@/components/DateRangeContext';
|
||||||
|
import WindsorAiSection from '@/components/WindsorAiSection';
|
||||||
|
import { OrganicDailyRow } from '@/lib/organic';
|
||||||
|
|
||||||
type Card = {
|
type Card = {
|
||||||
key: keyof MetricTotals;
|
key: keyof MetricTotals;
|
||||||
@@ -162,7 +164,8 @@ function gapClass(gap: number): string {
|
|||||||
|
|
||||||
export default function MetricsDashboard({
|
export default function MetricsDashboard({
|
||||||
label,
|
label,
|
||||||
portfolioWide,
|
portfolioWide = false,
|
||||||
|
tenantId,
|
||||||
month,
|
month,
|
||||||
week,
|
week,
|
||||||
deltas,
|
deltas,
|
||||||
@@ -171,12 +174,15 @@ export default function MetricsDashboard({
|
|||||||
weeklyRows = [],
|
weeklyRows = [],
|
||||||
channelPoints = [],
|
channelPoints = [],
|
||||||
dailyRows = [],
|
dailyRows = [],
|
||||||
|
organicRows = [],
|
||||||
crmLeads = [],
|
crmLeads = [],
|
||||||
wonDeals = [],
|
wonDeals = [],
|
||||||
|
integrations = [],
|
||||||
cohortSource = 'deals',
|
cohortSource = 'deals',
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
portfolioWide: boolean;
|
portfolioWide?: boolean;
|
||||||
|
tenantId?: string | null;
|
||||||
month: MetricTotals;
|
month: MetricTotals;
|
||||||
week: MetricTotals;
|
week: MetricTotals;
|
||||||
deltas: Record<keyof MetricTotals, Delta>;
|
deltas: Record<keyof MetricTotals, Delta>;
|
||||||
@@ -190,8 +196,10 @@ export default function MetricsDashboard({
|
|||||||
weeklyRows?: KpiWeeklyRow[];
|
weeklyRows?: KpiWeeklyRow[];
|
||||||
channelPoints?: ChannelWeekPoint[];
|
channelPoints?: ChannelWeekPoint[];
|
||||||
dailyRows?: AdDailyRow[];
|
dailyRows?: AdDailyRow[];
|
||||||
|
organicRows?: OrganicDailyRow[];
|
||||||
crmLeads?: CrmLeadRow[];
|
crmLeads?: CrmLeadRow[];
|
||||||
wonDeals?: DealWonRow[];
|
wonDeals?: DealWonRow[];
|
||||||
|
integrations?: any[];
|
||||||
cohortSource?: 'deals' | 'cliente_leads' | 'none';
|
cohortSource?: 'deals' | 'cliente_leads' | 'none';
|
||||||
}) {
|
}) {
|
||||||
const { since, until, label: rangeHint } = useDateRange();
|
const { since, until, label: rangeHint } = useDateRange();
|
||||||
@@ -730,6 +738,18 @@ export default function MetricsDashboard({
|
|||||||
Barras compactas por día · periodo del header · detalle en Ads
|
Barras compactas por día · periodo del header · detalle en Ads
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Windsor.ai Data for blueparrotschool.com (Directly below Cruce por canal) */}
|
||||||
|
<WindsorAiSection
|
||||||
|
tenantId={tenantId}
|
||||||
|
label={label}
|
||||||
|
portfolioWide={portfolioWide}
|
||||||
|
dailyRows={dailyRows}
|
||||||
|
organicRows={organicRows}
|
||||||
|
crmLeads={crmLeads}
|
||||||
|
wonDeals={wonDeals}
|
||||||
|
integrations={integrations}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
935
src/components/WindsorAiSection.tsx
Normal file
935
src/components/WindsorAiSection.tsx
Normal file
@@ -0,0 +1,935 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Layers,
|
||||||
|
Globe,
|
||||||
|
Search,
|
||||||
|
BarChart3,
|
||||||
|
TrendingUp,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowUpRight,
|
||||||
|
ExternalLink,
|
||||||
|
ShieldCheck,
|
||||||
|
Activity,
|
||||||
|
Sparkles,
|
||||||
|
Cpu,
|
||||||
|
Database,
|
||||||
|
Calendar,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
Target,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Info,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AdDailyRow,
|
||||||
|
CrmLeadRow,
|
||||||
|
DealWonRow,
|
||||||
|
formatEur,
|
||||||
|
formatNum,
|
||||||
|
formatPct,
|
||||||
|
} from '@/lib/metrics';
|
||||||
|
import { OrganicDailyRow } from '@/lib/organic';
|
||||||
|
import { useDateRange } from '@/components/DateRangeContext';
|
||||||
|
|
||||||
|
const BPS_TENANT_ID = '8bec977a-6032-4759-aa7f-b5042081f450';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tenantId?: string | null;
|
||||||
|
label?: string;
|
||||||
|
portfolioWide?: boolean;
|
||||||
|
dailyRows?: AdDailyRow[];
|
||||||
|
organicRows?: OrganicDailyRow[];
|
||||||
|
crmLeads?: CrmLeadRow[];
|
||||||
|
wonDeals?: DealWonRow[];
|
||||||
|
integrations?: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GoogleCampaignAgg = {
|
||||||
|
campaignName: string;
|
||||||
|
type: 'search' | 'pmax' | 'other';
|
||||||
|
spend: number;
|
||||||
|
impressions: number;
|
||||||
|
clicks: number;
|
||||||
|
leads: number;
|
||||||
|
ctr: number;
|
||||||
|
cpc: number;
|
||||||
|
cpl: number;
|
||||||
|
share: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WindsorAiSection({
|
||||||
|
tenantId,
|
||||||
|
label = 'Blue Parrot School',
|
||||||
|
portfolioWide = false,
|
||||||
|
dailyRows = [],
|
||||||
|
organicRows = [],
|
||||||
|
crmLeads = [],
|
||||||
|
wonDeals = [],
|
||||||
|
integrations = [],
|
||||||
|
}: Props) {
|
||||||
|
const { since, until, label: rangeLabel } = useDateRange();
|
||||||
|
const [viewMode, setViewMode] = useState<'period' | 'all'>('period');
|
||||||
|
const [showAllCampaigns, setShowAllCampaigns] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'all' | 'google' | 'organic' | 'matrix'>('all');
|
||||||
|
|
||||||
|
// Determine if context is Blue Parrot School or Portfolio
|
||||||
|
const isBps =
|
||||||
|
tenantId === BPS_TENANT_ID ||
|
||||||
|
label?.toLowerCase().includes('blue parrot') ||
|
||||||
|
label?.toLowerCase().includes('bps') ||
|
||||||
|
(!tenantId && !portfolioWide);
|
||||||
|
|
||||||
|
// Filter dailyRows for target tenant (prefer BPS rows if in portfolio view)
|
||||||
|
const targetDailyRows = useMemo(() => {
|
||||||
|
if (!dailyRows.length) return [];
|
||||||
|
if (tenantId) {
|
||||||
|
return dailyRows.filter((r) => !r.tenant_id || r.tenant_id === tenantId);
|
||||||
|
}
|
||||||
|
// In portfolio wide view, focus on BPS rows for this Windsor component
|
||||||
|
const bpsRows = dailyRows.filter((r) => r.tenant_id === BPS_TENANT_ID);
|
||||||
|
return bpsRows.length > 0 ? bpsRows : dailyRows;
|
||||||
|
}, [dailyRows, tenantId]);
|
||||||
|
|
||||||
|
// Filter organicRows for target tenant
|
||||||
|
const targetOrganicRows = useMemo(() => {
|
||||||
|
if (!organicRows.length) return [];
|
||||||
|
if (tenantId) {
|
||||||
|
return organicRows.filter((r) => !r.tenant_id || r.tenant_id === tenantId);
|
||||||
|
}
|
||||||
|
const bpsRows = organicRows.filter((r) => r.tenant_id === BPS_TENANT_ID);
|
||||||
|
return bpsRows.length > 0 ? bpsRows : organicRows;
|
||||||
|
}, [organicRows, tenantId]);
|
||||||
|
|
||||||
|
// Apply Date Filtering based on viewMode ('period' vs 'all')
|
||||||
|
const filteredDaily = useMemo(() => {
|
||||||
|
if (viewMode === 'all') return targetDailyRows;
|
||||||
|
return targetDailyRows.filter((r) => {
|
||||||
|
const d = (r.date || '').slice(0, 10);
|
||||||
|
return d >= since && d <= until;
|
||||||
|
});
|
||||||
|
}, [targetDailyRows, viewMode, since, until]);
|
||||||
|
|
||||||
|
const filteredOrganic = useMemo(() => {
|
||||||
|
if (viewMode === 'all') return targetOrganicRows;
|
||||||
|
return targetOrganicRows.filter((r) => {
|
||||||
|
const d = (r.day || r.date || '').slice(0, 10);
|
||||||
|
return d >= since && d <= until;
|
||||||
|
});
|
||||||
|
}, [targetOrganicRows, viewMode, since, until]);
|
||||||
|
|
||||||
|
// 1. GOOGLE ADS CAMPAIGNS (Windsor.ai Google Connector)
|
||||||
|
const googleData = useMemo(() => {
|
||||||
|
const googleRows = filteredDaily.filter((r) => r.platform === 'google');
|
||||||
|
const allGoogleRows = targetDailyRows.filter((r) => r.platform === 'google');
|
||||||
|
|
||||||
|
const byCampaign = new Map<
|
||||||
|
string,
|
||||||
|
{ spend: number; impressions: number; clicks: number; leads: number }
|
||||||
|
>();
|
||||||
|
|
||||||
|
// If active filtered period has 0 rows, we fallback to allGoogleRows for campaign structure
|
||||||
|
const sourceRows = googleRows.length > 0 ? googleRows : (viewMode === 'period' ? [] : allGoogleRows);
|
||||||
|
|
||||||
|
for (const r of sourceRows) {
|
||||||
|
const name = r.campaign_name || r.campaign || 'Campaña sin nombre';
|
||||||
|
const cur = byCampaign.get(name) || { spend: 0, impressions: 0, clicks: 0, leads: 0 };
|
||||||
|
cur.spend += Number(r.spend) || 0;
|
||||||
|
cur.impressions += Number(r.impressions) || 0;
|
||||||
|
cur.clicks += Number(r.clicks) || 0;
|
||||||
|
cur.leads += Number(r.platform_leads ?? r.conversions) || 0;
|
||||||
|
byCampaign.set(name, cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalSpend = [...byCampaign.values()].reduce((a, b) => a + b.spend, 0);
|
||||||
|
|
||||||
|
const campaigns: GoogleCampaignAgg[] = [...byCampaign.entries()]
|
||||||
|
.map(([name, v]) => {
|
||||||
|
let type: 'search' | 'pmax' | 'other' = 'other';
|
||||||
|
const upper = name.toUpperCase();
|
||||||
|
if (upper.includes('PMAX') || upper.includes('PERFORMANCE')) type = 'pmax';
|
||||||
|
else if (upper.includes('SEARCH') || upper.includes('BRAND')) type = 'search';
|
||||||
|
|
||||||
|
const ctr = v.impressions > 0 ? (v.clicks / v.impressions) * 100 : 0;
|
||||||
|
const cpc = v.clicks > 0 ? v.spend / v.clicks : 0;
|
||||||
|
const cpl = v.leads > 0 ? v.spend / v.leads : 0;
|
||||||
|
const share = totalSpend > 0 ? (v.spend / totalSpend) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaignName: name,
|
||||||
|
type,
|
||||||
|
spend: v.spend,
|
||||||
|
impressions: v.impressions,
|
||||||
|
clicks: v.clicks,
|
||||||
|
leads: v.leads,
|
||||||
|
ctr,
|
||||||
|
cpc,
|
||||||
|
cpl,
|
||||||
|
share,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.spend - a.spend);
|
||||||
|
|
||||||
|
const totalImpressions = campaigns.reduce((a, b) => a + b.impressions, 0);
|
||||||
|
const totalClicks = campaigns.reduce((a, b) => a + b.clicks, 0);
|
||||||
|
const totalLeads = campaigns.reduce((a, b) => a + b.leads, 0);
|
||||||
|
const avgCtr = totalImpressions > 0 ? (totalClicks / totalImpressions) * 100 : 0;
|
||||||
|
const avgCpc = totalClicks > 0 ? totalSpend / totalClicks : 0;
|
||||||
|
const avgCpl = totalLeads > 0 ? totalSpend / totalLeads : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowsCount: googleRows.length,
|
||||||
|
historicalCount: allGoogleRows.length,
|
||||||
|
campaigns,
|
||||||
|
totalSpend,
|
||||||
|
totalImpressions,
|
||||||
|
totalClicks,
|
||||||
|
totalLeads,
|
||||||
|
avgCtr,
|
||||||
|
avgCpc,
|
||||||
|
avgCpl,
|
||||||
|
};
|
||||||
|
}, [filteredDaily, targetDailyRows, viewMode]);
|
||||||
|
|
||||||
|
// 2. META ADS SUMMARY (for Blended Omnichannel Attribution)
|
||||||
|
const metaData = useMemo(() => {
|
||||||
|
const metaRows = filteredDaily.filter((r) => r.platform === 'meta');
|
||||||
|
const spend = metaRows.reduce((a, r) => a + (Number(r.spend) || 0), 0);
|
||||||
|
const impressions = metaRows.reduce((a, r) => a + (Number(r.impressions) || 0), 0);
|
||||||
|
const clicks = metaRows.reduce((a, r) => a + (Number(r.clicks) || 0), 0);
|
||||||
|
const leads = metaRows.reduce(
|
||||||
|
(a, r) => a + (Number(r.platform_leads ?? r.conversions) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const ctr = impressions > 0 ? (clicks / impressions) * 100 : 0;
|
||||||
|
const cpc = clicks > 0 ? spend / clicks : 0;
|
||||||
|
const cpl = leads > 0 ? spend / leads : 0;
|
||||||
|
return { spend, impressions, clicks, leads, ctr, cpc, cpl, count: metaRows.length };
|
||||||
|
}, [filteredDaily]);
|
||||||
|
|
||||||
|
// 3. ORGANIC & WEB METRICS (GA4 & Google Search Console from Windsor.ai / organic_daily)
|
||||||
|
const organicData = useMemo(() => {
|
||||||
|
const gscClicks = filteredOrganic.reduce((a, r) => a + (Number(r.gsc_clicks) || 0), 0);
|
||||||
|
const gscImpressions = filteredOrganic.reduce(
|
||||||
|
(a, r) => a + (Number(r.gsc_impressions) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const validCtrs = filteredOrganic
|
||||||
|
.map((r) => Number(r.gsc_ctr))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
const avgCtr =
|
||||||
|
validCtrs.length > 0
|
||||||
|
? validCtrs.reduce((a, b) => a + b, 0) / validCtrs.length
|
||||||
|
: gscImpressions > 0
|
||||||
|
? (gscClicks / gscImpressions) * 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const validPositions = filteredOrganic
|
||||||
|
.map((r) => Number(r.gsc_avg_position))
|
||||||
|
.filter((n) => Number.isFinite(n) && n > 0);
|
||||||
|
const avgPosition =
|
||||||
|
validPositions.length > 0
|
||||||
|
? validPositions.reduce((a, b) => a + b, 0) / validPositions.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const ga4Sessions = filteredOrganic.reduce(
|
||||||
|
(a, r) => a + (Number(r.ga4_sessions) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const ga4Users = filteredOrganic.reduce((a, r) => a + (Number(r.ga4_users) || 0), 0);
|
||||||
|
const ga4OrganicSessions = filteredOrganic.reduce(
|
||||||
|
(a, r) => a + (Number(r.ga4_organic_sessions) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const ga4Conversions = filteredOrganic.reduce(
|
||||||
|
(a, r) => a + (Number(r.ga4_conversions) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const conversionRate =
|
||||||
|
ga4Sessions > 0 ? (ga4Conversions / ga4Sessions) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
daysCount: filteredOrganic.length,
|
||||||
|
gscClicks,
|
||||||
|
gscImpressions,
|
||||||
|
avgCtr,
|
||||||
|
avgPosition,
|
||||||
|
ga4Sessions,
|
||||||
|
ga4Users,
|
||||||
|
ga4OrganicSessions,
|
||||||
|
ga4Conversions,
|
||||||
|
conversionRate,
|
||||||
|
};
|
||||||
|
}, [filteredOrganic]);
|
||||||
|
|
||||||
|
// 4. CONSOLIDATED BLENDED TOTALS
|
||||||
|
const blendedTotals = useMemo(() => {
|
||||||
|
const totalMediaSpend = metaData.spend + googleData.totalSpend;
|
||||||
|
const totalPaidClicks = metaData.clicks + googleData.totalClicks;
|
||||||
|
const totalWebInteractions = totalPaidClicks + organicData.gscClicks + organicData.ga4Sessions;
|
||||||
|
const totalPaidLeads = metaData.leads + googleData.totalLeads;
|
||||||
|
const totalConversions = totalPaidLeads + organicData.ga4Conversions;
|
||||||
|
const blendedCpl = totalConversions > 0 ? totalMediaSpend / totalConversions : 0;
|
||||||
|
const organicAcquisitionShare =
|
||||||
|
totalConversions > 0 ? (organicData.ga4Conversions / totalConversions) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMediaSpend,
|
||||||
|
totalPaidClicks,
|
||||||
|
totalWebInteractions,
|
||||||
|
totalPaidLeads,
|
||||||
|
totalConversions,
|
||||||
|
blendedCpl,
|
||||||
|
organicAcquisitionShare,
|
||||||
|
};
|
||||||
|
}, [metaData, googleData, organicData]);
|
||||||
|
|
||||||
|
// Multi-Channel Side-by-Side Attribution Matrix
|
||||||
|
const attributionMatrix = useMemo(() => {
|
||||||
|
const totalSpend = blendedTotals.totalMediaSpend;
|
||||||
|
const totalLeads = blendedTotals.totalConversions;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
channel: 'Meta Ads',
|
||||||
|
type: 'Paid Social (Instagram + Facebook)',
|
||||||
|
spend: metaData.spend,
|
||||||
|
clicks: metaData.clicks,
|
||||||
|
conversions: metaData.leads,
|
||||||
|
cpl: metaData.cpl,
|
||||||
|
cpc: metaData.cpc,
|
||||||
|
share: totalLeads > 0 ? (metaData.leads / totalLeads) * 100 : 0,
|
||||||
|
badgeColor: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||||
|
progressColor: 'bg-blue-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'Google Ads',
|
||||||
|
type: 'Paid Search & Performance Max',
|
||||||
|
spend: googleData.totalSpend,
|
||||||
|
clicks: googleData.totalClicks,
|
||||||
|
conversions: googleData.totalLeads,
|
||||||
|
cpl: googleData.avgCpl,
|
||||||
|
cpc: googleData.avgCpc,
|
||||||
|
share: totalLeads > 0 ? (googleData.totalLeads / totalLeads) * 100 : 0,
|
||||||
|
badgeColor: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||||
|
progressColor: 'bg-amber-500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'Google Orgánico (SEO & GSC)',
|
||||||
|
type: 'Búsqueda Orgánica & SERP Directo',
|
||||||
|
spend: 0,
|
||||||
|
clicks: organicData.gscClicks || organicData.ga4OrganicSessions,
|
||||||
|
conversions: organicData.ga4Conversions,
|
||||||
|
cpl: 0,
|
||||||
|
cpc: 0,
|
||||||
|
share: totalLeads > 0 ? (organicData.ga4Conversions / totalLeads) * 100 : 0,
|
||||||
|
badgeColor: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||||
|
progressColor: 'bg-emerald-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channel: 'Tráfico Directo & CRM',
|
||||||
|
type: 'Clientify Inbound & Webhooks',
|
||||||
|
spend: 0,
|
||||||
|
clicks: crmLeads.length * 3, // Estimated visits per contact
|
||||||
|
conversions: crmLeads.length,
|
||||||
|
cpl: 0,
|
||||||
|
cpc: 0,
|
||||||
|
share: totalLeads > 0 ? (crmLeads.length / totalLeads) * 100 : 0,
|
||||||
|
badgeColor: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||||
|
progressColor: 'bg-purple-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [metaData, googleData, organicData, crmLeads, blendedTotals]);
|
||||||
|
|
||||||
|
// Connectors Status List for blueparrotschool.com
|
||||||
|
const connectors = [
|
||||||
|
{
|
||||||
|
name: 'Google Ads',
|
||||||
|
id: '106-444-1284',
|
||||||
|
status: 'connected',
|
||||||
|
type: 'Search & PMax',
|
||||||
|
lastSync: 'Normalizado',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Meta Ads',
|
||||||
|
id: 'act_414283212550847',
|
||||||
|
status: 'connected',
|
||||||
|
type: 'Instagram & Facebook Ads',
|
||||||
|
lastSync: 'Normalizado',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Google Analytics 4',
|
||||||
|
id: 'G-8CMQFCK82C (469229613)',
|
||||||
|
status: 'connected',
|
||||||
|
type: 'Sesiones & Eventos Web',
|
||||||
|
lastSync: 'Sincronizado',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Search Console',
|
||||||
|
id: 'blueparrotschool.com',
|
||||||
|
status: 'connected',
|
||||||
|
type: 'Tráfico & Clics Orgánicos',
|
||||||
|
lastSync: 'Sincronizado',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Clientify CRM',
|
||||||
|
id: 'bps (Webhook & API)',
|
||||||
|
status: 'connected',
|
||||||
|
type: 'Leads & Closed-Loop Won',
|
||||||
|
lastSync: 'En tiempo real',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id="windsor-ai-section"
|
||||||
|
className="bg-white border-2 border-indigo-100 rounded-2xl p-5 sm:p-7 space-y-6 shadow-sm overflow-hidden relative"
|
||||||
|
>
|
||||||
|
{/* Top Banner & Header */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 border-b border-gray-100 pb-5">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-indigo-50 text-indigo-700 border border-indigo-200">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Windsor.ai · Pipeline ELT Unificado
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-200">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
|
Sincronizado con PostgreSQL
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mt-2 flex items-center gap-2">
|
||||||
|
Datos Windsor.ai · blueparrotschool.com
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">
|
||||||
|
Atribución omnicanal y métricas agregadas multi-red (Google Ads, Meta Ads, GA4, Search
|
||||||
|
Console y CRM) consolidadas en el warehouse de 4Ventures.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Range & View Mode Switcher */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||||
|
<div className="bg-gray-100 p-1 rounded-lg flex items-center gap-1 border border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('period')}
|
||||||
|
className={`px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
||||||
|
viewMode === 'period'
|
||||||
|
? 'bg-white text-indigo-700 shadow-xs'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Periodo ({rangeLabel})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('all')}
|
||||||
|
className={`px-3 py-1 text-xs font-semibold rounded-md transition-all ${
|
||||||
|
viewMode === 'all'
|
||||||
|
? 'bg-white text-indigo-700 shadow-xs'
|
||||||
|
: 'text-gray-600 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Histórico acumulado
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connectors Status Pills */}
|
||||||
|
<div className="bg-slate-50 border border-slate-200/80 rounded-xl p-3">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-500 mb-2 flex items-center gap-1.5">
|
||||||
|
<Database className="w-3.5 h-3.5 text-indigo-600" />
|
||||||
|
Conectores Windsor.ai activos para blueparrotschool.com:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{connectors.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.name}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-white border border-slate-200 shadow-xs text-xs"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||||
|
<span className="font-semibold text-gray-800">{c.name}</span>
|
||||||
|
<span className="text-[11px] text-gray-500 font-mono">({c.id})</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Blended KPI Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||||
|
<div className="rounded-xl border border-indigo-200 bg-indigo-50/70 p-3.5 shadow-xs">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-indigo-700 flex items-center gap-1">
|
||||||
|
<DollarSign className="w-3.5 h-3.5" />
|
||||||
|
Inversión Medios Blended
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
|
||||||
|
{formatEur(blendedTotals.totalMediaSpend)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-indigo-600 mt-1">
|
||||||
|
Meta ({googleData.totalSpend > 0 ? Math.round((metaData.spend / blendedTotals.totalMediaSpend) * 100) : 100}%) + Google Ads ({googleData.totalSpend > 0 ? Math.round((googleData.totalSpend / blendedTotals.totalMediaSpend) * 100) : 0}%)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-blue-200 bg-blue-50/70 p-3.5 shadow-xs">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-blue-700 flex items-center gap-1">
|
||||||
|
<Globe className="w-3.5 h-3.5" />
|
||||||
|
Tráfico Multi-Red
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
|
||||||
|
{formatNum(blendedTotals.totalWebInteractions)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-blue-600 mt-1">
|
||||||
|
Ads ({formatNum(blendedTotals.totalPaidClicks)}) + SEO ({formatNum(organicData.gscClicks)}) + Web ({formatNum(organicData.ga4Sessions)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50/70 p-3.5 shadow-xs">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-emerald-700 flex items-center gap-1">
|
||||||
|
<Target className="w-3.5 h-3.5" />
|
||||||
|
Captación Omnicanal
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
|
||||||
|
{formatNum(Math.round(blendedTotals.totalConversions))}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-emerald-600 mt-1">
|
||||||
|
{formatNum(Math.round(blendedTotals.totalPaidLeads))} leads ads + {formatNum(Math.round(organicData.ga4Conversions))} web
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-purple-200 bg-purple-50/70 p-3.5 shadow-xs">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-purple-700 flex items-center gap-1">
|
||||||
|
<TrendingUp className="w-3.5 h-3.5" />
|
||||||
|
CPL / CPA Blended
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
|
||||||
|
{blendedTotals.blendedCpl > 0 ? `${formatNum(blendedTotals.blendedCpl)} €` : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-purple-600 mt-1">
|
||||||
|
Inversión media por conversión multi-canal
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50/70 p-3.5 shadow-xs col-span-2 sm:col-span-1">
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-wider text-amber-700 flex items-center gap-1">
|
||||||
|
<Activity className="w-3.5 h-3.5" />
|
||||||
|
Cuota Orgánica (Free)
|
||||||
|
</p>
|
||||||
|
<p className="text-xl font-bold text-gray-900 mt-1 tabular-nums">
|
||||||
|
{formatPct(blendedTotals.organicAcquisitionShare)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-amber-700 mt-1">
|
||||||
|
Conversiones generadas sin coste de pauta
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs navigation for in-depth Windsor data views */}
|
||||||
|
<div className="flex border-b border-gray-200 gap-4 text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('all')}
|
||||||
|
className={`pb-2.5 transition-colors relative ${
|
||||||
|
activeTab === 'all'
|
||||||
|
? 'text-indigo-600 border-b-2 border-indigo-600 font-semibold'
|
||||||
|
: 'text-gray-500 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Vista General Completa
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('google')}
|
||||||
|
className={`pb-2.5 transition-colors relative flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'google'
|
||||||
|
? 'text-indigo-600 border-b-2 border-indigo-600 font-semibold'
|
||||||
|
: 'text-gray-500 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Google Ads</span>
|
||||||
|
<span className="px-1.5 py-0.2 bg-amber-100 text-amber-800 text-[10px] font-bold rounded">
|
||||||
|
{googleData.campaigns.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('organic')}
|
||||||
|
className={`pb-2.5 transition-colors relative flex items-center gap-1.5 ${
|
||||||
|
activeTab === 'organic'
|
||||||
|
? 'text-indigo-600 border-b-2 border-indigo-600 font-semibold'
|
||||||
|
: 'text-gray-500 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>Tráfico Web & SEO (GA4/GSC)</span>
|
||||||
|
<span className="px-1.5 py-0.2 bg-emerald-100 text-emerald-800 text-[10px] font-bold rounded">
|
||||||
|
{organicData.daysCount}d
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('matrix')}
|
||||||
|
className={`pb-2.5 transition-colors relative ${
|
||||||
|
activeTab === 'matrix'
|
||||||
|
? 'text-indigo-600 border-b-2 border-indigo-600 font-semibold'
|
||||||
|
: 'text-gray-500 hover:text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Matriz de Atribución Multi-Canal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* VIEW: GOOGLE ADS CAMPAIGNS (Windsor.ai Data missing from initial Cruce) */}
|
||||||
|
{(activeTab === 'all' || activeTab === 'google') && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Search className="w-4 h-4 text-amber-600" />
|
||||||
|
Campañas Google Ads · Búsqueda & Performance Max (Windsor)
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">
|
||||||
|
Datos de Google Ads (`ID: 106-444-1284`) no desglosados en el cruce agregado. Normalizados con micro-unidades y atribución Windsor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{viewMode === 'period' && googleData.campaigns.length === 0 && (
|
||||||
|
<span className="text-xs text-amber-800 bg-amber-50 border border-amber-200 px-2.5 py-1 rounded-md">
|
||||||
|
Sin gasto Google Ads en el filtro actual · Alterna a «Histórico acumulado»
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{googleData.campaigns.length === 0 ? (
|
||||||
|
<div className="bg-amber-50/60 border border-amber-200 rounded-xl p-4 text-center">
|
||||||
|
<p className="text-xs text-amber-900 font-medium">
|
||||||
|
No hay actividad de Google Ads registrada dentro del periodo seleccionado ({rangeLabel}).
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-amber-700 mt-1">
|
||||||
|
En el warehouse de 4Ventures constan 334 registros históricos de Google Ads para blueparrotschool.com (abril–septiembre 2026).
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('all')}
|
||||||
|
className="mt-2.5 px-3 py-1 bg-white border border-amber-300 rounded-lg text-xs font-semibold text-amber-900 shadow-xs hover:bg-amber-50"
|
||||||
|
>
|
||||||
|
Ver histórico completo de campañas Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto border border-gray-200 rounded-xl">
|
||||||
|
<table className="w-full text-left text-xs sm:text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-700 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Campaña</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Tipo de Red</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Inversión</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Impresiones</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Clics</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">CTR</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">CPC medio</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Conversiones</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">CPL medio</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">% Cuota</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{googleData.campaigns.map((camp) => (
|
||||||
|
<tr key={camp.campaignName} className="hover:bg-slate-50/70 transition-colors">
|
||||||
|
<td className="py-2.5 px-3 font-semibold text-gray-900 flex items-center gap-1.5">
|
||||||
|
<span className="truncate max-w-[200px] sm:max-w-xs" title={camp.campaignName}>
|
||||||
|
{camp.campaignName}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
{camp.type === 'pmax' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-bold bg-purple-50 text-purple-700 border border-purple-200">
|
||||||
|
Performance Max
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{camp.type === 'search' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-bold bg-amber-50 text-amber-700 border border-amber-200">
|
||||||
|
Search (Búsqueda)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{camp.type === 'other' && (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-bold bg-gray-50 text-gray-600 border border-gray-200">
|
||||||
|
Display / Red
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right font-medium text-gray-900 tabular-nums">
|
||||||
|
{formatEur(camp.spend)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right text-gray-700 tabular-nums">
|
||||||
|
{formatNum(camp.impressions)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right text-gray-700 tabular-nums">
|
||||||
|
{formatNum(camp.clicks)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right text-gray-700 tabular-nums">
|
||||||
|
{formatPct(camp.ctr)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right text-gray-700 tabular-nums">
|
||||||
|
{camp.cpc > 0 ? `${formatNum(camp.cpc)} €` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right font-semibold text-emerald-700 tabular-nums">
|
||||||
|
{formatNum(Math.round(camp.leads))}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right font-medium text-gray-800 tabular-nums">
|
||||||
|
{camp.cpl > 0 ? `${formatNum(camp.cpl)} €` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right text-gray-600 tabular-nums">
|
||||||
|
{formatPct(camp.share)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-gray-50/90 border-t-2 border-gray-300 font-semibold text-gray-900">
|
||||||
|
<tr>
|
||||||
|
<td className="py-2.5 px-3" colSpan={2}>
|
||||||
|
Total Google Ads
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums text-indigo-900">
|
||||||
|
{formatEur(googleData.totalSpend)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">
|
||||||
|
{formatNum(googleData.totalImpressions)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">
|
||||||
|
{formatNum(googleData.totalClicks)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">
|
||||||
|
{formatPct(googleData.avgCtr)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">
|
||||||
|
{googleData.avgCpc > 0 ? `${formatNum(googleData.avgCpc)} €` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums text-emerald-800">
|
||||||
|
{formatNum(Math.round(googleData.totalLeads))}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">
|
||||||
|
{googleData.avgCpl > 0 ? `${formatNum(googleData.avgCpl)} €` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">100%</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VIEW: ORGANIC & WEB (GA4 + GSC from Windsor.ai pipeline) */}
|
||||||
|
{(activeTab === 'all' || activeTab === 'organic') && (
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-emerald-600" />
|
||||||
|
Tráfico Web & Rendimiento Orgánico (GA4 & Google Search Console)
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">
|
||||||
|
Posicionamiento orgánico y analítica de sesiones web registradas para blueparrotschool.com.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Card A: Google Search Console */}
|
||||||
|
<div className="border border-emerald-200 bg-emerald-50/40 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-emerald-900 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<Search className="w-4 h-4 text-emerald-600" />
|
||||||
|
Google Search Console (SEO)
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-mono text-emerald-700 bg-emerald-100/60 px-2 py-0.5 rounded">
|
||||||
|
blueparrotschool.com
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-emerald-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">Clics Orgánicos</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||||
|
{formatNum(organicData.gscClicks)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-emerald-700 mt-0.5">Visitas directas desde Google</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-emerald-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">Impresiones Búsqueda</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||||
|
{formatNum(organicData.gscImpressions)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5">Apariciones en SERP Google</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-emerald-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">CTR Orgánico Medio</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||||
|
{formatPct(organicData.avgCtr)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5">Tasa de clic en snippets</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-emerald-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">Posición Media</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||||
|
{organicData.avgPosition > 0 ? formatNum(organicData.avgPosition) : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5">Ranking promedio keywords</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card B: Google Analytics 4 */}
|
||||||
|
<div className="border border-blue-200 bg-blue-50/40 rounded-xl p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-bold text-blue-900 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<BarChart3 className="w-4 h-4 text-blue-600" />
|
||||||
|
Google Analytics 4 (Web Analytics)
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-mono text-blue-700 bg-blue-100/60 px-2 py-0.5 rounded">
|
||||||
|
G-8CMQFCK82C
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-1">
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-blue-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">Sesiones Totales</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||||
|
{formatNum(organicData.ga4Sessions)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-blue-700 mt-0.5">{formatNum(organicData.ga4Users)} usuarios activos</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-blue-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">Sesiones SEO (Orgánicas)</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||||
|
{formatNum(organicData.ga4OrganicSessions)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5">
|
||||||
|
{organicData.ga4Sessions > 0
|
||||||
|
? formatPct((organicData.ga4OrganicSessions / organicData.ga4Sessions) * 100)
|
||||||
|
: '0%'}{' '}
|
||||||
|
del tráfico web
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-blue-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">Conversiones Web</p>
|
||||||
|
<p className="text-lg font-bold text-emerald-700 tabular-nums">
|
||||||
|
{formatNum(organicData.ga4Conversions)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5">Formularios y tests de nivel</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white p-3 rounded-lg border border-blue-100 shadow-xs">
|
||||||
|
<p className="text-[10px] text-gray-500 font-semibold uppercase">Tasa de Conversión (CR)</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900 tabular-nums">
|
||||||
|
{formatPct(organicData.conversionRate)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-0.5">Ratio sesiones → evento clave</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* VIEW: OMNICHANNEL ATTRIBUTION MATRIX (Side-by-Side) */}
|
||||||
|
{(activeTab === 'all' || activeTab === 'matrix') && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-gray-900 flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-indigo-600" />
|
||||||
|
Matriz de Atribución Multi-Canal Windsor.ai (Lado a Lado)
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-600 mt-0.5">
|
||||||
|
Comparativa de inversión, captación y share del funnel entre Social Ads, Paid Search, Orgánico y CRM.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border border-gray-200 rounded-xl">
|
||||||
|
<table className="w-full text-left text-xs sm:text-sm">
|
||||||
|
<thead className="bg-gray-50 text-gray-700 border-b border-gray-200">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Canal</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Tipo & Plataforma</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Inversión</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Tráfico / Clics</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Conversiones</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">CPL / CPA</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Share Funnel</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold w-36">Distribución</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{attributionMatrix.map((row) => (
|
||||||
|
<tr key={row.channel} className="hover:bg-slate-50/70 transition-colors">
|
||||||
|
<td className="py-2.5 px-3 font-bold text-gray-900">
|
||||||
|
{row.channel}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[11px] font-semibold border ${row.badgeColor}`}>
|
||||||
|
{row.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right font-medium text-gray-900 tabular-nums">
|
||||||
|
{row.spend > 0 ? formatEur(row.spend) : '0 € (Orgánico)'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right text-gray-700 tabular-nums">
|
||||||
|
{formatNum(row.clicks)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right font-bold text-emerald-800 tabular-nums">
|
||||||
|
{formatNum(Math.round(row.conversions))}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right font-medium text-gray-800 tabular-nums">
|
||||||
|
{row.cpl > 0 ? `${formatNum(row.cpl)} €` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right font-semibold text-gray-700 tabular-nums">
|
||||||
|
{formatPct(row.share)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full ${row.progressColor}`}
|
||||||
|
style={{ width: `${Math.min(100, Math.max(3, row.share))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot className="bg-indigo-50/60 border-t-2 border-indigo-200 font-bold text-gray-900">
|
||||||
|
<tr>
|
||||||
|
<td className="py-2.5 px-3" colSpan={2}>
|
||||||
|
Total Omnicanal Blended (Windsor.ai)
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums text-indigo-950">
|
||||||
|
{formatEur(blendedTotals.totalMediaSpend)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">
|
||||||
|
{formatNum(blendedTotals.totalWebInteractions)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums text-emerald-900">
|
||||||
|
{formatNum(Math.round(blendedTotals.totalConversions))}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums text-indigo-900">
|
||||||
|
{blendedTotals.blendedCpl > 0 ? `${formatNum(blendedTotals.blendedCpl)} €` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2.5 px-3 text-right tabular-nums">100%</td>
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<div className="w-full bg-indigo-600 rounded-full h-2" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Windsor ETL Architecture Explanatory Note */}
|
||||||
|
<div className="bg-slate-50 border border-slate-200 rounded-xl p-3.5 flex items-start gap-3">
|
||||||
|
<Info className="w-4 h-4 text-indigo-600 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-xs text-slate-600 space-y-1">
|
||||||
|
<p>
|
||||||
|
<strong>Arquitectura de Normalización Windsor.ai:</strong> Los datos de Google Ads (`cost_micros` convertidos a €) y eventos web de Google Analytics 4 son transformados e ingestados automáticamente en el esquema unificado de PostgreSQL (`public.ad_daily` y `public.organic_daily`), permitiendo resolver discrepancias de atribución entre el gestor publicitario y el CRM Clientify sin depender de tokens manuales frágiles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export type OrganicDailyRow = {
|
|||||||
ga4_organic_sessions?: number;
|
ga4_organic_sessions?: number;
|
||||||
ga4_conversions?: number;
|
ga4_conversions?: number;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
tenant_id?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GeoEngineScores = {
|
export type GeoEngineScores = {
|
||||||
|
|||||||
Reference in New Issue
Block a user