Demographics Analytics - Frontend Implementation
🎯 Introducción
Esta guía proporciona todo lo necesario para implementar las funcionalidades de análisis demográfico en el frontend del CDP, incluyendo componentes React, hooks personalizados, y mejores prácticas de UI/UX.
📱 Principios de Diseño Mobile-First
Layout Responsive
// ✅ CORRECTO - Mobile First approach
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={3}>
<DemographicCard type="age" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<DemographicCard type="gender" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<DemographicCard type="location" />
</Grid>
<Grid item xs={12} sm={6} md={3}>
<DemographicCard type="completion" />
</Grid>
</Grid>
🔗 Servicios API
Demographics API Service
// services/demographicsAPI.js
import axios from 'axios';
const API_BASE = process.env.REACT_APP_API_URL || 'https://nerdistan-datalake-production.up.railway.app';
export const demographicsAPI = {
// Análisis demográfico completo
getAnalysis: async (tenantId) => {
try {
const response = await axios.get(`${API_BASE}/api/v2/cdp/analytics/demographics`, {
params: { tenant_id: tenantId }
});
return response.data;
} catch (error) {
console.error('Error fetching demographics:', error);
throw error;
}
},
// Clientes con datos demográficos
getCustomersWithDemographics: async (tenantId, { limit = 50, offset = 0 } = {}) => {
try {
const response = await axios.get(`${API_BASE}/api/cdp/v2/customers`, {
params: { tenant_id: tenantId, limit, offset }
});
return response.data;
} catch (error) {
console.error('Error fetching customers with demographics:', error);
throw error;
}
},
// RFM con insights demográficos
getRFMWithDemographics: async (tenantId) => {
try {
const response = await axios.get(`${API_BASE}/api/v2/cdp/analytics/rfm`, {
params: { tenant_id: tenantId }
});
return response.data;
} catch (error) {
console.error('Error fetching RFM with demographics:', error);
throw error;
}
}
};
🎣 Hooks Personalizados
useDemographics Hook
// hooks/useDemographics.js
import { useState, useEffect } from 'react';
import { demographicsAPI } from '../services/demographicsAPI';
export const useDemographics = (tenantId) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchDemographics = async () => {
if (!tenantId) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await demographicsAPI.getAnalysis(tenantId);
setData(response.data);
} catch (err) {
setError(err.message || 'Error fetching demographic data');
console.error('Demographics fetch error:', err);
} finally {
setLoading(false);
}
};
fetchDemographics();
}, [tenantId]);
const refresh = () => {
if (tenantId) {
setLoading(true);
fetchDemographics();
}
};
return { data, loading, error, refresh };
};
useCustomersWithDemographics Hook
// hooks/useCustomersWithDemographics.js
import { useState, useEffect, useCallback } from 'react';
import { demographicsAPI } from '../services/demographicsAPI';
export const useCustomersWithDemographics = (tenantId, options = {}) => {
const [customers, setCustomers] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [pagination, setPagination] = useState(null);
const { limit = 50, offset = 0 } = options;
const fetchCustomers = useCallback(async () => {
if (!tenantId) return;
try {
setLoading(true);
setError(null);
const response = await demographicsAPI.getCustomersWithDemographics(tenantId, { limit, offset });
setCustomers(response.data.customers);
setPagination(response.data.pagination);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [tenantId, limit, offset]);
useEffect(() => {
fetchCustomers();
}, [fetchCustomers]);
return { customers, loading, error, pagination, refresh: fetchCustomers };
};
🧩 Componentes Principales
DemographicsOverview
// components/DemographicsOverview.jsx
import React from 'react';
import {
Card,
CardContent,
Typography,
Grid,
Skeleton,
Box,
Chip
} from '@mui/material';
import {
Person,
LocationOn,
Cake,
Assessment
} from '@mui/icons-material';
const DemographicsOverview = ({ data, loading }) => {
if (loading) {
return (
<Grid container spacing={2}>
{[1, 2, 3, 4].map((i) => (
<Grid item xs={12} sm={6} md={3} key={i}>
<Card>
<CardContent>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="80%" height={40} />
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
}
if (!data?.summary) return null;
const { summary } = data;
const cards = [
{
title: 'Total Clientes',
value: summary.total_customers?.toLocaleString() || '0',
icon: <Person sx={{ color: '#1976d2' }} />,
color: '#1976d2'
},
{
title: 'Edad Promedio',
value: `${summary.overall_avg_age || 0} años`,
icon: <Cake sx={{ color: '#f57c00' }} />,
color: '#f57c00'
},
{
title: 'Datos de Género',
value: `${summary.completion_rates?.gender || 0}%`,
icon: <Assessment sx={{ color: '#388e3c' }} />,
color: '#388e3c'
},
{
title: 'Datos de Ubicación',
value: `${summary.completion_rates?.location || 0}%`,
icon: <LocationOn sx={{ color: '#7b1fa2' }} />,
color: '#7b1fa2'
}
];
return (
<Grid container spacing={2}>
{cards.map((card, index) => (
<Grid item xs={12} sm={6} md={3} key={index}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Box display="flex" alignItems="center" mb={1}>
{card.icon}
<Typography variant="h6" ml={1} sx={{ fontSize: { xs: '0.9rem', sm: '1rem' } }}>
{card.title}
</Typography>
</Box>
<Typography
variant="h4"
sx={{
color: card.color,
fontWeight: 'bold',
fontSize: { xs: '1.5rem', sm: '2rem' }
}}
>
{card.value}
</Typography>
{card.title === 'Total Clientes' && (
<Box mt={1}>
<Chip
label={`${summary.customers_with_age || 0} con edad`}
size="small"
variant="outlined"
/>
</Box>
)}
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
};
export default DemographicsOverview;
AgeDistributionChart
// components/AgeDistributionChart.jsx
import React from 'react';
import {
Card,
CardContent,
CardHeader,
Box,
Typography
} from '@mui/material';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip
} from 'recharts';
const AgeDistributionChart = ({ data, loading }) => {
if (loading || !data?.age_analysis) {
return (
<Card>
<CardHeader title="Distribución por Edad" />
<CardContent>
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
<Typography>Cargando datos de edad...</Typography>
</Box>
</CardContent>
</Card>
);
}
const COLORS = {
'Generación Z': '#10B981',
'Millennials': '#F59E0B',
'Generación X': '#EF4444',
'Baby Boomers': '#8B5CF6'
};
const chartData = data.age_analysis.map(item => ({
name: item.generation,
value: item.customer_count,
percentage: item.percentage,
age_segment: item.age_segment
}));
const CustomTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<Box sx={{
bgcolor: 'background.paper',
p: 2,
border: 1,
borderColor: 'divider',
borderRadius: 1
}}>
<Typography variant="subtitle2">{data.name}</Typography>
<Typography variant="body2">
Rango: {data.age_segment}
</Typography>
<Typography variant="body2">
Clientes: {data.value.toLocaleString()}
</Typography>
<Typography variant="body2">
Porcentaje: {data.percentage}%
</Typography>
</Box>
);
}
return null;
};
const CustomLabel = ({ cx, cy, midAngle, innerRadius, outerRadius, percent }) => {
if (percent < 0.05) return null; // No mostrar labels para segmentos < 5%
const RADIAN = Math.PI / 180;
const radius = innerRadius + (outerRadius - innerRadius) * 0.5;
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
return (
<text
x={x}
y={y}
fill="white"
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={12}
fontWeight="bold"
>
{`${(percent * 100).toFixed(0)}%`}
</text>
);
};
return (
<Card>
<CardHeader
title="Distribución por Edad"
subheader={`${chartData.length} generaciones identificadas`}
/>
<CardContent>
<ResponsiveContainer width="100%" height={400}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
labelLine={false}
label={CustomLabel}
outerRadius={120}
fill="#8884d8"
dataKey="value"
>
{chartData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={COLORS[entry.name] || '#94A3B8'}
/>
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{ paddingTop: '20px' }}
iconType="circle"
/>
</PieChart>
</ResponsiveContainer>
{/* Stats summary for mobile */}
<Box sx={{ display: { xs: 'block', md: 'none' }, mt: 2 }}>
{chartData.map((item, index) => (
<Box key={index} display="flex" justifyContent="space-between" py={1}>
<Box display="flex" alignItems="center">
<Box
sx={{
width: 12,
height: 12,
bgcolor: COLORS[item.name] || '#94A3B8',
mr: 1,
borderRadius: '50%'
}}
/>
<Typography variant="body2">{item.name}</Typography>
</Box>
<Typography variant="body2" fontWeight="bold">
{item.value.toLocaleString()} ({item.percentage}%)
</Typography>
</Box>
))}
</Box>
</CardContent>
</Card>
);
};
export default AgeDistributionChart;
GenderAnalysisCards
// components/GenderAnalysisCards.jsx
import React from 'react';
import {
Card,
CardContent,
Typography,
Grid,
Box,
Chip,
LinearProgress
} from '@mui/material';
import {
Male,
Female,
Transgender
} from '@mui/icons-material';
const GenderAnalysisCards = ({ data, loading }) => {
if (loading || !data?.gender_analysis) {
return (
<Grid container spacing={2}>
{[1, 2, 3].map((i) => (
<Grid item xs={12} sm={6} md={4} key={i}>
<Card>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
<Box sx={{ width: 24, height: 24, bgcolor: 'grey.300', mr: 1 }} />
<Typography variant="h6">Cargando...</Typography>
</Box>
<LinearProgress />
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
}
const getGenderIcon = (genderCode) => {
switch(genderCode) {
case 'M': return <Male sx={{ color: '#3B82F6', fontSize: 28 }} />;
case 'F': return <Female sx={{ color: '#EC4899', fontSize: 28 }} />;
default: return <Transgender sx={{ color: '#8B5CF6', fontSize: 28 }} />;
}
};
const getGenderColor = (genderCode) => {
switch(genderCode) {
case 'M': return '#3B82F6';
case 'F': return '#EC4899';
default: return '#8B5CF6';
}
};
return (
<Grid container spacing={2}>
{data.gender_analysis.map((gender, index) => (
<Grid item xs={12} sm={6} md={4} key={index}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Box display="flex" alignItems="center" mb={2}>
{getGenderIcon(gender.gender_code)}
<Typography variant="h6" ml={1} sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }}>
{gender.gender_label}
</Typography>
</Box>
<Box mb={2}>
<Typography variant="h4" sx={{
color: getGenderColor(gender.gender_code),
fontWeight: 'bold',
fontSize: { xs: '1.5rem', sm: '2rem' }
}}>
{gender.customer_count?.toLocaleString() || '0'}
</Typography>
<Typography variant="body2" color="text.secondary">
{gender.percentage}% del total
</Typography>
</Box>
<Box sx={{ '& > *': { mb: 1 } }}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="body2">Edad promedio:</Typography>
<Chip
label={`${gender.avg_age || 0} años`}
size="small"
color="primary"
variant="outlined"
/>
</Box>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="body2">Champions:</Typography>
<Chip
label={gender.champions?.toLocaleString() || '0'}
size="small"
color="success"
variant="outlined"
/>
</Box>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="body2">Gasto promedio:</Typography>
<Typography variant="body2" fontWeight="bold">
${(gender.avg_spent || 0).toLocaleString()}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="body2">Órdenes promedio:</Typography>
<Typography variant="body2" fontWeight="bold">
{gender.avg_orders || 0}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
);
};
export default GenderAnalysisCards;
MarketingSegmentsTable
// components/MarketingSegmentsTable.jsx
import React, { useState } from 'react';
import {
Card,
CardHeader,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Chip,
Box,
Typography,
IconButton,
Collapse
} from '@mui/material';
import {
Download,
ExpandMore,
ExpandLess,
Facebook,
Google
} from '@mui/icons-material';
const MarketingSegmentsTable = ({ data, loading }) => {
const [expandedRows, setExpandedRows] = useState(new Set());
if (loading || !data?.marketing_segments) {
return (
<Card>
<CardHeader title="Segmentos para Marketing" />
<CardContent>
<Typography>Cargando segmentos de marketing...</Typography>
</CardContent>
</Card>
);
}
const toggleRow = (index) => {
const newExpanded = new Set(expandedRows);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedRows(newExpanded);
};
const exportSegment = (segment) => {
// Implementar lógica de exportación
const csvContent = `Age Range,Gender,Customers,Avg Spent,High Value
${segment.google_age_range},${segment.gender},${segment.targetable_customers},${segment.avg_spent},${segment.high_value}`;
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `segment_${segment.google_age_range}_${segment.gender}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
};
const getTotalCustomers = () => {
return data.marketing_segments.reduce((sum, segment) => sum + segment.targetable_customers, 0);
};
return (
<Card>
<CardHeader
title="Segmentos para Marketing Digital"
subheader={`${data.marketing_segments.length} segmentos • ${getTotalCustomers().toLocaleString()} clientes targetables`}
/>
<CardContent sx={{ p: { xs: 1, sm: 2 } }}>
{/* Vista Mobile - Cards */}
<Box sx={{ display: { xs: 'block', md: 'none' } }}>
{data.marketing_segments.map((segment, index) => (
<Card key={index} sx={{ mb: 2 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">
{segment.google_age_range} años • {segment.gender === 'M' ? 'Masculino' : 'Femenino'}
</Typography>
<IconButton onClick={() => toggleRow(index)}>
{expandedRows.has(index) ? <ExpandLess /> : <ExpandMore />}
</IconButton>
</Box>
<Box display="flex" justifyContent="space-between" mb={1}>
<Typography variant="body2">Clientes:</Typography>
<Typography variant="body2" fontWeight="bold">
{segment.targetable_customers.toLocaleString()}
</Typography>
</Box>
<Collapse in={expandedRows.has(index)}>
<Box sx={{ mt: 2, '& > *': { mb: 1 } }}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Gasto promedio:</Typography>
<Typography variant="body2">${segment.avg_spent.toLocaleString()}</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Alto valor:</Typography>
<Chip label={segment.high_value} size="small" color="primary" />
</Box>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="body2">Facebook:</Typography>
<Chip
icon={<Facebook />}
label={segment.facebook_age_range}
size="small"
color="primary"
variant="outlined"
/>
</Box>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="body2">Google:</Typography>
<Chip
icon={<Google />}
label={segment.google_age_range}
size="small"
color="secondary"
variant="outlined"
/>
</Box>
<Button
fullWidth
variant="outlined"
startIcon={<Download />}
onClick={() => exportSegment(segment)}
sx={{ mt: 2 }}
>
Exportar Segmento
</Button>
</Box>
</Collapse>
</CardContent>
</Card>
))}
</Box>
{/* Vista Desktop - Tabla */}
<TableContainer
component={Paper}
sx={{
display: { xs: 'none', md: 'block' },
maxHeight: 600,
overflowX: 'auto'
}}
>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell>Rango de Edad</TableCell>
<TableCell>Género</TableCell>
<TableCell align="right">Clientes</TableCell>
<TableCell align="right">Gasto Promedio</TableCell>
<TableCell align="center">Alto Valor</TableCell>
<TableCell align="center">Facebook</TableCell>
<TableCell align="center">Google</TableCell>
<TableCell align="center">Acciones</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.marketing_segments.map((segment, index) => (
<TableRow key={index} hover>
<TableCell>{segment.google_age_range}</TableCell>
<TableCell>
<Chip
label={segment.gender === 'M' ? 'Masculino' : 'Femenino'}
color={segment.gender === 'M' ? 'primary' : 'secondary'}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{segment.targetable_customers.toLocaleString()}
</Typography>
</TableCell>
<TableCell align="right">
${segment.avg_spent.toLocaleString()}
</TableCell>
<TableCell align="center">
<Chip
label={segment.high_value}
size="small"
color={segment.high_value > 0 ? 'success' : 'default'}
/>
</TableCell>
<TableCell align="center">
<Chip
icon={<Facebook sx={{ fontSize: 16 }} />}
label={segment.facebook_age_range}
size="small"
color="primary"
variant="outlined"
/>
</TableCell>
<TableCell align="center">
<Chip
icon={<Google sx={{ fontSize: 16 }} />}
label={segment.google_age_range}
size="small"
color="secondary"
variant="outlined"
/>
</TableCell>
<TableCell align="center">
<Button
size="small"
startIcon={<Download />}
onClick={() => exportSegment(segment)}
sx={{ minWidth: 'auto' }}
>
Exportar
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
);
};
export default MarketingSegmentsTable;
📱 Página Principal de Demographics
DemographicsPage
// pages/DemographicsPage.jsx
import React from 'react';
import {
Container,
Typography,
Box,
Grid,
Alert,
CircularProgress,
Fab
} from '@mui/material';
import { Refresh } from '@mui/icons-material';
import { useDemographics } from '../hooks/useDemographics';
import DemographicsOverview from '../components/DemographicsOverview';
import AgeDistributionChart from '../components/AgeDistributionChart';
import GenderAnalysisCards from '../components/GenderAnalysisCards';
import MarketingSegmentsTable from '../components/MarketingSegmentsTable';
const DemographicsPage = ({ tenantId }) => {
const { data, loading, error, refresh } = useDemographics(tenantId);
if (error) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Alert severity="error" sx={{ mb: 3 }}>
Error al cargar datos demográficos: {error}
</Alert>
</Container>
);
}
return (
<Container maxWidth="xl" sx={{ py: { xs: 2, sm: 4 } }}>
{/* Header */}
<Box mb={4}>
<Typography
variant="h4"
gutterBottom
sx={{ fontSize: { xs: '1.75rem', sm: '2.125rem' } }}
>
Análisis Demogr áfico
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}
>
Insights detallados sobre las características demográficas de tus clientes
</Typography>
</Box>
{/* Loading State */}
{loading && !data && (
<Box display="flex" justifyContent="center" py={8}>
<CircularProgress size={60} />
</Box>
)}
{/* Content */}
{data && (
<Grid container spacing={3}>
{/* Overview Cards */}
<Grid item xs={12}>
<DemographicsOverview data={data} loading={loading} />
</Grid>
{/* Age Distribution */}
<Grid item xs={12} lg={6}>
<AgeDistributionChart data={data} loading={loading} />
</Grid>
{/* Gender Analysis */}
<Grid item xs={12} lg={6}>
<Box>
<Typography variant="h6" gutterBottom sx={{ px: 2 }}>
Análisis por Género
</Typography>
<GenderAnalysisCards data={data} loading={loading} />
</Box>
</Grid>
{/* Marketing Segments */}
<Grid item xs={12}>
<MarketingSegmentsTable data={data} loading={loading} />
</Grid>
</Grid>
)}
{/* Refresh FAB */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: { xs: 16, sm: 24 },
right: { xs: 16, sm: 24 }
}}
onClick={refresh}
disabled={loading}
>
<Refresh />
</Fab>
</Container>
);
};
export default DemographicsPage;
🎨 Estilos y Temas
Colores Demográficos
// theme/demographicColors.js
export const demographicColors = {
// Géneros
gender: {
male: '#3B82F6', // Azul
female: '#EC4899', // Rosa
unisex: '#8B5CF6', // Púrpura
unknown: '#6B7280' // Gris
},
// Generaciones
generation: {
genZ: '#10B981', // Verde
millennials: '#F59E0B', // Amarillo
genX: '#EF4444', // Rojo
boomers: '#8B5CF6' // Púrpura
},
// Ubicaciones
location: {
metropolitana: '#0EA5E9', // Azul cielo
centro: '#F97316', // Naranja
cuyo: '#84CC16', // Lima
norte: '#F59E0B', // Amarillo
patagonia: '#8B5CF6' // Púrpura
}
};
// Utility functions
export const getGenderColor = (genderCode) => {
switch(genderCode) {
case 'M': return demographicColors.gender.male;
case 'F': return demographicColors.gender.female;
case 'U': return demographicColors.gender.unisex;
default: return demographicColors.gender.unknown;
}
};
export const getGenerationColor = (generation) => {
if (generation?.includes('Z')) return demographicColors.generation.genZ;
if (generation?.includes('Millennials')) return demographicColors.generation.millennials;
if (generation?.includes('X')) return demographicColors.generation.genX;
return demographicColors.generation.boomers;
};
🧪 Datos Mock para Desarrollo
Mock Data Service
// services/mockDemographicsData.js
export const mockDemographicsData = {
summary: {
total_customers: 4671,
customers_with_age: 845,
customers_with_gender: 4548,
customers_with_location: 4671,
overall_avg_age: 32.3,
completion_rates: {
age: 18.09,
gender: 97.37,
location: 100.0
}
},
age_analysis: [
{
age_segment: "25-34",
generation: "Millennials",
customer_count: 233,
avg_age: 32.0,
avg_spent: 12514.88,
avg_orders: 0.1,
high_value_customers: 2,
male_count: 117,
female_count: 113,
percentage: 27.57
},
{
age_segment: "18-24",
generation: "Generación Z",
customer_count: 104,
avg_age: 22.0,
avg_spent: 124891.04,
avg_orders: 0.7,
high_value_customers: 4,
male_count: 58,
female_count: 41,
percentage: 12.31
},
{
age_segment: "35-44",
generation: "Millennials",
customer_count: 89,
avg_age: 39.0,
avg_spent: 98765.43,
avg_orders: 0.5,
high_value_customers: 3,
male_count: 45,
female_count: 44,
percentage: 10.53
}
],
gender_analysis: [
{
gender_label: "Masculino",
gender_code: "M",
customer_count: 2330,
avg_spent: 331317.94,
avg_orders: 1.2,
avg_age: 32.0,
champions: 157,
millennials_core: 224,
percentage: 49.88
},
{
gender_label: "Femenino",
gender_code: "F",
customer_count: 2218,
avg_spent: 224681.24,
avg_orders: 1.1,
avg_age: 32.6,
champions: 140,
millennials_core: 204,
percentage: 47.48
},
{
gender_label: "Unisex/Otro",
gender_code: "U",
customer_count: 123,
avg_spent: 156789.12,
avg_orders: 0.8,
avg_age: 31.2,
champions: 10,
millennials_core: 15,
percentage: 2.64
}
],
geographic_analysis: [
{
province: "Buenos Aires",
region: "Región Metropolitana",
customer_count: 4671,
avg_spent: 280616.68,
avg_orders: 1.2,
avg_age: 32.3,
male_count: 2330,
female_count: 2218,
percentage: 100.0
}
],
marketing_segments: [
{
facebook_age_range: "25_34",
google_age_range: "25-34",
gender: "M",
targetable_customers: 224,
avg_spent: 12514.88,
high_value: 2
},
{
facebook_age_range: "25_34",
google_age_range: "25-34",
gender: "F",
targetable_customers: 210,
avg_spent: 11890.45,
high_value: 1
},
{
facebook_age_range: "18_24",
google_age_range: "18-24",
gender: "M",
targetable_customers: 58,
avg_spent: 124891.04,
high_value: 4
},
{
facebook_age_range: "18_24",
google_age_range: "18-24",
gender: "F",
targetable_customers: 41,
avg_spent: 98765.43,
high_value: 2
}
]
};
// Mock API para desarrollo
export const mockDemographicsAPI = {
getAnalysis: async (tenantId) => {
await new Promise(resolve => setTimeout(resolve, 1500)); // Simular latencia
return {
success: true,
data: mockDemographicsData
};
}
};
🔧 Utilidades y Helpers
Validation Helpers
// utils/demographicValidation.js
// Validar datos demográficos
export const validateDemographicData = (customer) => {
return {
hasAge: customer.estimated_age !== null && customer.estimated_age !== undefined,
hasGender: customer.inferred_gender !== null && customer.inferred_gender !== undefined,
hasLocation: customer.corrected_province !== null && customer.corrected_province !== undefined,
isComplete: customer.estimated_age && customer.inferred_gender && customer.corrected_province
};
};
// Formatear edad para display
export const formatAge = (age) => {
if (!age) return 'Sin datos';
return `${age} años`;
};
// Formatear género para display
export const formatGender = (genderCode) => {
const genderMap = {
'M': 'Masculino',
'F': 'Femenino',
'U': 'Unisex/Otro',
null: 'Sin datos',
undefined: 'Sin datos'
};
return genderMap[genderCode] || 'Sin datos';
};
// Formatear generación
export const formatGeneration = (generation) => {
if (!generation) return 'Sin datos';
return generation;
};
// Obtener color de generación
export const getGenerationColor = (generation) => {
if (!generation) return '#94A3B8';
if (generation.includes('Z')) return '#10B981';
if (generation.includes('Millennials')) return '#F59E0B';
if (generation.includes('X')) return '#EF4444';
return '#8B5CF6';
};
// Validar completitud de datos
export const getDataCompleteness = (customers) => {
if (!customers || customers.length === 0) return { age: 0, gender: 0, location: 0 };
const total = customers.length;
const withAge = customers.filter(c => c.estimated_age).length;
const withGender = customers.filter(c => c.inferred_gender).length;
const withLocation = customers.filter(c => c.corrected_province).length;
return {
age: ((withAge / total) * 100).toFixed(2),
gender: ((withGender / total) * 100).toFixed(2),
location: ((withLocation / total) * 100).toFixed(2)
};
};
📋 Checklist de Implementación
Setup Básico
- ✅ Instalar dependencias: recharts, @mui/icons-material
- ✅ Configurar servicios API
- ✅ Implementar hooks personalizados
- ✅ Crear componentes base
Componentes
- ✅ DemographicsOverview
- ✅ AgeDistributionChart
- ✅ GenderAnalysisCards
- ✅ MarketingSegmentsTable
- ✅ DemographicsPage principal
Funcionalidades
- ✅ Visualización de datos mock
- ✅ Integración con API real
- ✅ Responsive design mobile-first
- ✅ Exportación de segmentos
- ⚠️ Filtros avanzados (próximamente)
- ⚠️ Comparación histórica (próximamente)
Testing
- ✅ Testing con datos mock
- ✅ Testing con API real (Tenant 22)
- ✅ Testing responsive en devices
- ⚠️ Testing de performance
- ⚠️ Testing de accesibilidad
🚀 Próximos Pasos
Mejoras Inmediatas
- Filtros dinámicos: Implementar filtros por edad, género, región
- Comparación temporal: Mostrar cambios demográficos en el tiempo
- Exportación avanzada: Múltiples formatos de exportación
- Notificaciones: Alerts para cambios significativos
Funcionalidades Avanzadas
- Predicción demográfica: ML para predecir cambios
- Segmentación automática: Crear audiencias automáticamente
- Integración publicitaria: Sync directo con Facebook/Google
- Dashboard ejecutivo: Vista resumida para management
Implementación: ✅ Lista para desarrollo Componentes: ✅ 5 componentes principales creados Mobile-First: ✅ Responsive design implementado Última actualización: 21 de Septiembre 2025