diff --git a/src/app/dashboard/kpis/page.tsx b/src/app/dashboard/kpis/page.tsx index 2526862..9edc769 100644 --- a/src/app/dashboard/kpis/page.tsx +++ b/src/app/dashboard/kpis/page.tsx @@ -17,6 +17,7 @@ import { sumWeekly, } from '@/lib/metrics'; import { isAgencyRole, serviceHeaders, supabaseUrl, userRole } from '@/lib/agency'; +import { OrganicDailyRow } from '@/lib/organic'; 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 { + 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() { const supabase = await createClient(); const { @@ -270,6 +335,8 @@ export default async function KpisPage() { let daily: AdDailyRow[] = []; let crmLeads: CrmLeadRow[] = []; let wonDeals: DealWonRow[] = []; + let organic: OrganicDailyRow[] = []; + let integrations: any[] = []; if (agency && (ctx.portfolioWide || ctx.viaView)) { if (ctx.portfolioWide) { @@ -280,6 +347,8 @@ export default async function KpisPage() { daily = await fetchAdDaily(null, true); crmLeads = []; wonDeals = await fetchWonDeals(null, true); + organic = await fetchOrganicDaily(null, true); + integrations = []; } else if (ctx.tenantId) { const tid = encodeURIComponent(ctx.tenantId); const w = await restGet( @@ -289,6 +358,8 @@ export default async function KpisPage() { daily = await fetchAdDaily(ctx.tenantId, true); crmLeads = await fetchCrmLeads(ctx.tenantId, true); wonDeals = await fetchWonDeals(ctx.tenantId, true); + organic = await fetchOrganicDaily(ctx.tenantId, true); + integrations = await fetchIntegrations(ctx.tenantId, true); } } else { const { data: w } = await supabase @@ -299,6 +370,8 @@ export default async function KpisPage() { daily = await fetchAdDaily(ctx.tenantId, false); crmLeads = await fetchCrmLeads(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); @@ -319,6 +392,7 @@ export default async function KpisPage() { ); diff --git a/src/components/MetricsDashboard.tsx b/src/components/MetricsDashboard.tsx index d7154c2..060054b 100644 --- a/src/components/MetricsDashboard.tsx +++ b/src/components/MetricsDashboard.tsx @@ -37,6 +37,8 @@ import { import { PageHeader } from '@/components/ui'; import SyncNowButton from '@/components/SyncNowButton'; import { useDateRange } from '@/components/DateRangeContext'; +import WindsorAiSection from '@/components/WindsorAiSection'; +import { OrganicDailyRow } from '@/lib/organic'; type Card = { key: keyof MetricTotals; @@ -162,7 +164,8 @@ function gapClass(gap: number): string { export default function MetricsDashboard({ label, - portfolioWide, + portfolioWide = false, + tenantId, month, week, deltas, @@ -171,12 +174,15 @@ export default function MetricsDashboard({ weeklyRows = [], channelPoints = [], dailyRows = [], + organicRows = [], crmLeads = [], wonDeals = [], + integrations = [], cohortSource = 'deals', }: { label: string; - portfolioWide: boolean; + portfolioWide?: boolean; + tenantId?: string | null; month: MetricTotals; week: MetricTotals; deltas: Record; @@ -190,8 +196,10 @@ export default function MetricsDashboard({ weeklyRows?: KpiWeeklyRow[]; channelPoints?: ChannelWeekPoint[]; dailyRows?: AdDailyRow[]; + organicRows?: OrganicDailyRow[]; crmLeads?: CrmLeadRow[]; wonDeals?: DealWonRow[]; + integrations?: any[]; cohortSource?: 'deals' | 'cliente_leads' | 'none'; }) { 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

+ + {/* Windsor.ai Data for blueparrotschool.com (Directly below Cruce por canal) */} + ); } diff --git a/src/components/WindsorAiSection.tsx b/src/components/WindsorAiSection.tsx new file mode 100644 index 0000000..cab3664 --- /dev/null +++ b/src/components/WindsorAiSection.tsx @@ -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 ( +
+ {/* Top Banner & Header */} +
+
+
+ + + Windsor.ai · Pipeline ELT Unificado + + + + Sincronizado con PostgreSQL + +
+ +

+ Datos Windsor.ai · blueparrotschool.com +

+

+ Atribución omnicanal y métricas agregadas multi-red (Google Ads, Meta Ads, GA4, Search + Console y CRM) consolidadas en el warehouse de 4Ventures. +

+
+ + {/* Range & View Mode Switcher */} +
+
+ + +
+
+
+ + {/* Connectors Status Pills */} +
+

+ + Conectores Windsor.ai activos para blueparrotschool.com: +

+
+ {connectors.map((c) => ( +
+ + {c.name} + ({c.id}) +
+ ))} +
+
+ + {/* Top Blended KPI Cards */} +
+
+

+ + Inversión Medios Blended +

+

+ {formatEur(blendedTotals.totalMediaSpend)} +

+

+ 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}%) +

+
+ +
+

+ + Tráfico Multi-Red +

+

+ {formatNum(blendedTotals.totalWebInteractions)} +

+

+ Ads ({formatNum(blendedTotals.totalPaidClicks)}) + SEO ({formatNum(organicData.gscClicks)}) + Web ({formatNum(organicData.ga4Sessions)}) +

+
+ +
+

+ + Captación Omnicanal +

+

+ {formatNum(Math.round(blendedTotals.totalConversions))} +

+

+ {formatNum(Math.round(blendedTotals.totalPaidLeads))} leads ads + {formatNum(Math.round(organicData.ga4Conversions))} web +

+
+ +
+

+ + CPL / CPA Blended +

+

+ {blendedTotals.blendedCpl > 0 ? `${formatNum(blendedTotals.blendedCpl)} €` : '—'} +

+

+ Inversión media por conversión multi-canal +

+
+ +
+

+ + Cuota Orgánica (Free) +

+

+ {formatPct(blendedTotals.organicAcquisitionShare)} +

+

+ Conversiones generadas sin coste de pauta +

+
+
+ + {/* Tabs navigation for in-depth Windsor data views */} +
+ + + + +
+ + {/* VIEW: GOOGLE ADS CAMPAIGNS (Windsor.ai Data missing from initial Cruce) */} + {(activeTab === 'all' || activeTab === 'google') && ( +
+
+
+

+ + Campañas Google Ads · Búsqueda & Performance Max (Windsor) +

+

+ Datos de Google Ads (`ID: 106-444-1284`) no desglosados en el cruce agregado. Normalizados con micro-unidades y atribución Windsor. +

+
+ {viewMode === 'period' && googleData.campaigns.length === 0 && ( + + Sin gasto Google Ads en el filtro actual · Alterna a «Histórico acumulado» + + )} +
+ + {googleData.campaigns.length === 0 ? ( +
+

+ No hay actividad de Google Ads registrada dentro del periodo seleccionado ({rangeLabel}). +

+

+ En el warehouse de 4Ventures constan 334 registros históricos de Google Ads para blueparrotschool.com (abril–septiembre 2026). +

+ +
+ ) : ( +
+ + + + + + + + + + + + + + + + + {googleData.campaigns.map((camp) => ( + + + + + + + + + + + + + ))} + + + + + + + + + + + + + + +
CampañaTipo de RedInversiónImpresionesClicsCTRCPC medioConversionesCPL medio% Cuota
+ + {camp.campaignName} + + + {camp.type === 'pmax' && ( + + Performance Max + + )} + {camp.type === 'search' && ( + + Search (Búsqueda) + + )} + {camp.type === 'other' && ( + + Display / Red + + )} + + {formatEur(camp.spend)} + + {formatNum(camp.impressions)} + + {formatNum(camp.clicks)} + + {formatPct(camp.ctr)} + + {camp.cpc > 0 ? `${formatNum(camp.cpc)} €` : '—'} + + {formatNum(Math.round(camp.leads))} + + {camp.cpl > 0 ? `${formatNum(camp.cpl)} €` : '—'} + + {formatPct(camp.share)} +
+ Total Google Ads + + {formatEur(googleData.totalSpend)} + + {formatNum(googleData.totalImpressions)} + + {formatNum(googleData.totalClicks)} + + {formatPct(googleData.avgCtr)} + + {googleData.avgCpc > 0 ? `${formatNum(googleData.avgCpc)} €` : '—'} + + {formatNum(Math.round(googleData.totalLeads))} + + {googleData.avgCpl > 0 ? `${formatNum(googleData.avgCpl)} €` : '—'} + 100%
+
+ )} +
+ )} + + {/* VIEW: ORGANIC & WEB (GA4 + GSC from Windsor.ai pipeline) */} + {(activeTab === 'all' || activeTab === 'organic') && ( +
+
+
+

+ + Tráfico Web & Rendimiento Orgánico (GA4 & Google Search Console) +

+

+ Posicionamiento orgánico y analítica de sesiones web registradas para blueparrotschool.com. +

+
+
+ +
+ {/* Card A: Google Search Console */} +
+
+ + + Google Search Console (SEO) + + + blueparrotschool.com + +
+ +
+
+

Clics Orgánicos

+

+ {formatNum(organicData.gscClicks)} +

+

Visitas directas desde Google

+
+
+

Impresiones Búsqueda

+

+ {formatNum(organicData.gscImpressions)} +

+

Apariciones en SERP Google

+
+
+

CTR Orgánico Medio

+

+ {formatPct(organicData.avgCtr)} +

+

Tasa de clic en snippets

+
+
+

Posición Media

+

+ {organicData.avgPosition > 0 ? formatNum(organicData.avgPosition) : '—'} +

+

Ranking promedio keywords

+
+
+
+ + {/* Card B: Google Analytics 4 */} +
+
+ + + Google Analytics 4 (Web Analytics) + + + G-8CMQFCK82C + +
+ +
+
+

Sesiones Totales

+

+ {formatNum(organicData.ga4Sessions)} +

+

{formatNum(organicData.ga4Users)} usuarios activos

+
+
+

Sesiones SEO (Orgánicas)

+

+ {formatNum(organicData.ga4OrganicSessions)} +

+

+ {organicData.ga4Sessions > 0 + ? formatPct((organicData.ga4OrganicSessions / organicData.ga4Sessions) * 100) + : '0%'}{' '} + del tráfico web +

+
+
+

Conversiones Web

+

+ {formatNum(organicData.ga4Conversions)} +

+

Formularios y tests de nivel

+
+
+

Tasa de Conversión (CR)

+

+ {formatPct(organicData.conversionRate)} +

+

Ratio sesiones → evento clave

+
+
+
+
+
+ )} + + {/* VIEW: OMNICHANNEL ATTRIBUTION MATRIX (Side-by-Side) */} + {(activeTab === 'all' || activeTab === 'matrix') && ( +
+
+
+

+ + Matriz de Atribución Multi-Canal Windsor.ai (Lado a Lado) +

+

+ Comparativa de inversión, captación y share del funnel entre Social Ads, Paid Search, Orgánico y CRM. +

+
+
+ +
+ + + + + + + + + + + + + + + {attributionMatrix.map((row) => ( + + + + + + + + + + + ))} + + + + + + + + + + + + +
CanalTipo & PlataformaInversiónTráfico / ClicsConversionesCPL / CPAShare FunnelDistribución
+ {row.channel} + + + {row.type} + + + {row.spend > 0 ? formatEur(row.spend) : '0 € (Orgánico)'} + + {formatNum(row.clicks)} + + {formatNum(Math.round(row.conversions))} + + {row.cpl > 0 ? `${formatNum(row.cpl)} €` : '—'} + + {formatPct(row.share)} + +
+
+
+
+ Total Omnicanal Blended (Windsor.ai) + + {formatEur(blendedTotals.totalMediaSpend)} + + {formatNum(blendedTotals.totalWebInteractions)} + + {formatNum(Math.round(blendedTotals.totalConversions))} + + {blendedTotals.blendedCpl > 0 ? `${formatNum(blendedTotals.blendedCpl)} €` : '—'} + 100% +
+
+
+
+ )} + + {/* Windsor ETL Architecture Explanatory Note */} +
+ +
+

+ Arquitectura de Normalización Windsor.ai: 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. +

+
+
+
+ ); +} diff --git a/src/lib/organic.ts b/src/lib/organic.ts index b70b860..80457a7 100644 --- a/src/lib/organic.ts +++ b/src/lib/organic.ts @@ -12,6 +12,7 @@ export type OrganicDailyRow = { ga4_organic_sessions?: number; ga4_conversions?: number; source?: string; + tenant_id?: string; }; export type GeoEngineScores = {