feat: Refinamiento del tracking de analiticas y actualizacion del dashboard admin

This commit is contained in:
2026-03-04 20:36:31 -05:00
parent c376627d39
commit 4e17613d49
12 changed files with 189 additions and 62 deletions

View File

@ -17,8 +17,9 @@ onMounted(async () => {
if (busStopStore.selectedStop) {
analyticsService.logEvent({
event_name: 'stop_selected',
item_id: busStopStore.selectedStop.name,
properties: { stop_id: stopId }
entity_type: 'stop',
entity_id: stopId,
entity_name: busStopStore.selectedStop.name,
})
}
}

View File

@ -48,9 +48,10 @@ async function fetchData() {
business.value = bizData
coupons.value = allCoupons.filter(c => c.business_id === id)
analyticsService.logEvent({
event_name: 'screen_view',
screen_name: 'BusinessDetails',
item_id: bizData.name,
event_name: 'view_details',
entity_type: 'business',
entity_id: bizData.id,
entity_name: bizData.name,
properties: { business_id: id }
})
} catch (e) {
@ -71,6 +72,7 @@ const goBack = () => router.back()
function openMaps() {
if (!business.value) return
analyticsService.logEvent({ event_name: 'location_click', entity_type: 'business', entity_id: business.value.id, entity_name: business.value.name })
if (business.value.latitude && business.value.longitude) {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${business.value.latitude},${business.value.longitude}`, '_blank')
} else if (business.value.address) {
@ -80,6 +82,7 @@ function openMaps() {
function callPhone() {
if (business.value?.phone) {
analyticsService.logEvent({ event_name: 'contact_click', entity_type: 'business', entity_id: business.value.id, entity_name: business.value.name, properties: { channel: 'phone' } })
window.location.href = `tel:${business.value.phone}`
}
}
@ -87,20 +90,20 @@ function callPhone() {
function openWhatsApp() {
if (!business.value?.whatsapp) return
const num = business.value.whatsapp.replace(/\D/g, '')
analyticsService.logEvent({ event_name: 'business_whatsapp_click', item_id: business.value.name })
analyticsService.logEvent({ event_name: 'social_click', entity_type: 'business', entity_id: business.value.id, entity_name: business.value.name, properties: { platform: 'whatsapp' } })
window.open(`https://wa.me/${num}`, '_blank')
}
function openInstagram() {
if (!business.value?.instagram) return
const handle = business.value.instagram.replace('@', '')
analyticsService.logEvent({ event_name: 'business_instagram_click', item_id: business.value.name })
analyticsService.logEvent({ event_name: 'social_click', entity_type: 'business', entity_id: business.value.id, entity_name: business.value.name, properties: { platform: 'instagram' } })
window.open(`https://instagram.com/${handle}`, '_blank')
}
function openFacebook() {
if (!business.value?.facebook) return
analyticsService.logEvent({ event_name: 'business_facebook_click', item_id: business.value.name })
analyticsService.logEvent({ event_name: 'social_click', entity_type: 'business', entity_id: business.value.id, entity_name: business.value.name, properties: { platform: 'facebook' } })
const fb = business.value.facebook
window.open(fb.startsWith('http') ? fb : `https://facebook.com/${fb}`, '_blank')
}

View File

@ -50,19 +50,23 @@ function openCoupon(coupon: Coupon) {
showRedeemModal.value = true
analyticsService.logEvent({
event_name: 'promo_view',
item_id: coupon.title,
properties: { coupon_id: coupon.id, business: coupon.business?.name }
entity_type: 'coupon',
entity_id: coupon.id,
entity_name: coupon.title,
properties: { business: coupon.business?.name }
})
}
function handleDirections() {
if (!selectedCoupon.value) return
analyticsService.logEvent({
event_name: 'promo_click',
item_id: 'directions_' + selectedCoupon.value.business?.name,
event_name: 'location_click',
entity_type: 'coupon',
entity_id: selectedCoupon.value.id,
entity_name: selectedCoupon.value.title,
properties: {
coupon_id: selectedCoupon.value.id,
action: 'get_directions'
action: 'get_directions',
business: selectedCoupon.value.business?.name
}
})
window.open(`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedCoupon.value.business?.address || selectedCoupon.value.business?.name || '')}`, '_blank')

View File

@ -118,7 +118,12 @@ const isFiltering = computed(() =>
)
function handleExplore(biz: Business) {
analyticsService.logEvent({ event_name: 'promo_click', item_id: biz.name, properties: { business_id: biz.id } })
analyticsService.logEvent({
event_name: 'view_details',
entity_type: 'business',
entity_id: biz.id,
entity_name: biz.name
})
router.push('/business/' + biz.id)
}

View File

@ -57,7 +57,9 @@ const handleTaxiFilter = async () => {
const goToSchedules = (route: any) => {
analyticsService.logEvent({
event_name: 'route_selected',
item_id: route.name,
entity_type: 'route',
entity_id: route.id,
entity_name: route.name,
properties: { route_id: route.id }
})
routeStore.selectRoute(route.id, route.name)

View File

@ -126,7 +126,9 @@ function pickRoute(id: string, name: string) {
dropdownOpen.value = false
analyticsService.logEvent({
event_name: 'schedule_viewed',
item_id: name,
entity_type: 'route',
entity_id: id,
entity_name: name,
properties: { route_id: id }
})

View File

@ -142,17 +142,27 @@
<table class="data-table">
<thead>
<tr>
<th>Ruta</th>
<th>Conversión</th>
<th>Ratio</th>
<th>Shuttle</th>
<th>Interés (Vistas)</th>
<th>Reservas & Llamadas</th>
<th>Ratio de Conversión</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, id) in stats.shuttles" :key="id">
<td class="id-cell">{{ id }}</td>
<td>{{ calculateConversion(data.views, data.contacts) }}%</td>
<tr v-for="(data, name) in stats.shuttles" :key="name">
<td class="id-cell">{{ name }}</td>
<td>{{ data.views }}</td>
<td>
<div class="mini-bar"><div class="fill" :style="{ width: calculateConversion(data.views, data.contacts) + '%' }"></div></div>
<div style="display:flex; gap: 12px; align-items:center;">
<span title="WhatsApp" style="display:flex; align-items:center; gap:2px;"><span class="material-icons" style="font-size:14px; color:#25D366;">chat</span> {{ data.whatsapp }}</span>
<span title="Llamadas" style="display:flex; align-items:center; gap:2px;"><span class="material-icons" style="font-size:14px; color:#cbd5e1;">phone</span> {{ data.calls }}</span>
</div>
</td>
<td>
<div style="display:flex; flex-direction:column; gap:4px;">
<div class="mini-bar"><div class="fill" :style="{ width: calculateConversion(data.views, data.contacts) + '%' }"></div></div>
<span style="font-size: 0.65rem; color: #64748b; font-weight:700;">{{ calculateConversion(data.views, data.contacts) }}%</span>
</div>
</td>
</tr>
</tbody>
@ -202,15 +212,29 @@
<tr>
<th>Negocio</th>
<th>Visitas</th>
<th>Interacciones (R/LL/M)</th>
<th>Cupones</th>
<th>Favoritos</th>
<th>Salud</th>
</tr>
</thead>
<tbody>
<tr v-for="(data, id) in stats.businesses" :key="id">
<td class="id-cell">{{ id }}</td>
<tr v-for="(data, name) in stats.businesses" :key="name">
<td class="id-cell">{{ name }}</td>
<td>{{ data.views }}</td>
<td>
<div style="display:flex; gap:8px; font-size:0.75rem; color:#cbd5e1;">
<span title="Click en Redes Sociales">S: {{ data.social }}</span>
<span title="Llamadas Realizadas">L: {{ data.calls }}</span>
<span title="Visitas a Maps">M: {{ data.location }}</span>
</div>
</td>
<td>{{ data.promos }}</td>
<td>
<span style="display:flex; align-items:center; gap:2px;">
<span class="material-icons" style="font-size:14px; color:#e91e63;">favorite</span> {{ data.favs }}
</span>
</td>
<td>
<span class="status-pill" :class="getHealthClass(calculateConversion(data.views, data.promos))">
{{ getHealthLabel(calculateConversion(data.views, data.promos)) }}
@ -395,49 +419,78 @@ const getHealthLabel = (rate: any) => (parseFloat(rate) > 20 ? 'Alta' : parseFlo
onMounted(async () => {
try {
// Load all data in parallel
const [
{ count: userCount },
{ data: shuttles },
{ data: routes },
{ data: businesses }
{ data: events }
] = await Promise.all([
supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true),
supabase.from('shuttles').select('id, route_name'),
supabase.from('routes').select('id, name'),
supabase.from('businesses').select('id, name')
// In a production app with >1M rows we might use a group-by RPC
supabase.from('analytics_events').select('*')
])
const shuttleStats: any = {}
for (const s of (shuttles || [])) {
shuttleStats[s.route_name || s.id] = { views: Math.floor(Math.random() * 100), contacts: Math.floor(Math.random() * 20) }
}
const routeStats: any = {}
for (const r of (routes || [])) {
routeStats[r.name || r.id] = { views: Math.floor(Math.random() * 80), contacts: Math.floor(Math.random() * 15) }
}
const bizStats: any = {}
for (const b of (businesses || [])) {
bizStats[b.name || b.id] = { views: Math.floor(Math.random() * 60), promos: Math.floor(Math.random() * 10) }
let total_promo_clicks = 0
let total_shuttle_contacts = 0
let total_biz_views = 0
const safeRows = events || []
for (const ev of safeRows) {
const nameKey = ev.entity_name || ev.entity_id
if (ev.entity_type === 'shuttle') {
if (!shuttleStats[nameKey]) {
shuttleStats[nameKey] = { views: 0, contacts: 0, calls: 0, whatsapp: 0 }
}
if (ev.event_name === 'view_details') {
shuttleStats[nameKey].views++
} else if (ev.event_name === 'shuttle_contact') {
shuttleStats[nameKey].contacts++
total_shuttle_contacts++
if (ev.properties?.action === 'whatsapp') shuttleStats[nameKey].whatsapp++
if (ev.properties?.action === 'call') shuttleStats[nameKey].calls++
}
} else if (ev.entity_type === 'business') {
if (!bizStats[nameKey]) {
bizStats[nameKey] = { views: 0, promos: 0, favs: 0, social: 0, location: 0, calls: 0 }
}
if (ev.event_name === 'view_details') {
bizStats[nameKey].views++
total_biz_views++
} else if (ev.event_name === 'favorite_add') {
bizStats[nameKey].favs++
} else if (ev.event_name === 'social_click') {
bizStats[nameKey].social++
} else if (ev.event_name === 'location_click') {
bizStats[nameKey].location++
} else if (ev.event_name === 'contact_click') {
bizStats[nameKey].calls++
} else if (ev.event_name === 'promo_click') {
bizStats[nameKey].promos++
total_promo_clicks++
}
}
}
stats.value = {
shuttles: shuttleStats,
businesses: bizStats,
top_stops: [],
top_stops: [], // Legacy logic can still go here if stops tracking is added
users: {
registered_active: userCount || 0,
patterns: { registered: {}, guests: {} }
},
summary: {
total_shuttle_contacts: Object.values(shuttleStats).reduce((a: any, v: any) => a + v.contacts, 0),
total_promo_clicks: Object.values(bizStats).reduce((a: any, v: any) => a + v.promos, 0),
total_biz_views: Object.values(bizStats).reduce((a: any, v: any) => a + v.views, 0)
total_shuttle_contacts,
total_promo_clicks,
total_biz_views
}
}
} catch (error) { console.error(error); } finally { loading.value = false; }
} catch (error) { console.error('Error fetching analytics:', error); } finally { loading.value = false; }
});
</script>

View File

@ -195,7 +195,7 @@ const getTripTypeLabel = (type: string) => {
:href="`https://wa.me/${shuttle.contact_whatsapp.replace(/\+/g, '')}?text=Hola,%20me%20gustaría%20información%20sobre%20el%20shuttle%20de%20${shuttle.origin}%20a%20${shuttle.destination}`"
target="_blank"
class="flex justify-center items-center gap-2 p-3.5 bg-[#25D366] text-white rounded-xl font-bold hover:opacity-90 transition active:scale-95"
@click="analyticsService.logEvent({ event_name: 'shuttle_contact', item_id: shuttle.id, properties: { action: 'whatsapp' } })"
@click="analyticsService.logEvent({ event_name: 'shuttle_contact', entity_type: 'shuttle', entity_id: shuttle.id, entity_name: shuttle.company_name || 'shuttle', properties: { action: 'whatsapp' } })"
>
<span class="material-icons">chat</span>
{{ t('shuttle.bookWhatsapp') }}
@ -204,7 +204,7 @@ const getTripTypeLabel = (type: string) => {
<a v-if="shuttle.phone_number"
:href="`tel:${shuttle.phone_number}`"
class="flex justify-center items-center gap-2 p-3.5 bg-[var(--bg-primary)] text-[var(--text-primary)] rounded-xl font-bold hover:bg-[var(--hover-bg)] transition active:scale-95 border border-border"
@click="analyticsService.logEvent({ event_name: 'shuttle_contact', item_id: shuttle.id, properties: { action: 'call' } })"
@click="analyticsService.logEvent({ event_name: 'shuttle_contact', entity_type: 'shuttle', entity_id: shuttle.id, entity_name: shuttle.company_name || 'shuttle', properties: { action: 'call' } })"
>
<span class="material-icons">phone_in_talk</span>
{{ t('shuttle.callOperator') }}

View File

@ -63,7 +63,9 @@ const getShiftsDisplay = (taxi: Taxi) => {
const handleCall = (taxi: Taxi) => {
analyticsService.logEvent({
event_name: 'taxi_click',
item_id: taxi.owner_name,
entity_type: 'taxi',
entity_id: taxi.id,
entity_name: taxi.owner_name,
properties: {
action: 'call',
taxi_id: taxi.id,

View File

@ -23,7 +23,13 @@ const filteredShuttles = computed(() => {
})
})
const verDetalle = (shuttleId: string) => {
const verDetalle = (shuttleId: string, shuttleName: string) => {
analyticsService.logEvent({
event_name: 'view_details',
entity_type: 'shuttle',
entity_id: shuttleId,
entity_name: shuttleName
})
router.push({
name: 'ShuttleDetalle',
params: { id: shuttleId }
@ -118,7 +124,7 @@ onUnmounted(() => {
:key="shuttle.id"
v-memo="[shuttle.id]"
class="shuttle-card-premium glass-effect"
@click="verDetalle(shuttle.id)"
@click="verDetalle(shuttle.id, shuttle.company_name || `${shuttle.origin}-${shuttle.destination}`)"
>
<div class="card-image-wrap">
<img