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,
|
||||
} 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<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() {
|
||||
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<KpiWeeklyRow[]>(
|
||||
@@ -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() {
|
||||
<MetricsDashboard
|
||||
label={ctx.label}
|
||||
portfolioWide={ctx.portfolioWide}
|
||||
tenantId={ctx.tenantId}
|
||||
month={month}
|
||||
week={comparisons.currentWeek}
|
||||
deltas={comparisons.deltas}
|
||||
@@ -327,8 +401,10 @@ export default async function KpisPage() {
|
||||
weeklyRows={weekly}
|
||||
channelPoints={channelSeries.points}
|
||||
dailyRows={daily}
|
||||
organicRows={organic}
|
||||
crmLeads={crmLeads}
|
||||
wonDeals={wonDeals}
|
||||
integrations={integrations}
|
||||
cohortSource={cohort.source}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<keyof MetricTotals, Delta>;
|
||||
@@ -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
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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_conversions?: number;
|
||||
source?: string;
|
||||
tenant_id?: string;
|
||||
};
|
||||
|
||||
export type GeoEngineScores = {
|
||||
|
||||
Reference in New Issue
Block a user