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

@ -15,6 +15,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useFavoritesStore } from '@/stores/favorites' import { useFavoritesStore } from '@/stores/favorites'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { analyticsService } from '@/services/analyticsService'
const props = defineProps<{ const props = defineProps<{
itemType: 'coupon' | 'business' | 'taxi' | 'route' | 'stop' | 'shuttle' itemType: 'coupon' | 'business' | 'taxi' | 'route' | 'stop' | 'shuttle'
@ -59,6 +60,15 @@ async function handleToggle() {
props.itemName, props.itemName,
props.itemImage props.itemImage
) )
const isNowFavorited = favoritesStore.isFavorite(props.itemType, props.itemId)
const entityTypeMap: Record<string, any> = { business: 'business', shuttle: 'shuttle', coupon: 'coupon', stop: 'stop', route: 'route' }
analyticsService.logEvent({
event_name: isNowFavorited ? 'favorite_add' : 'favorite_remove',
entity_type: entityTypeMap[props.itemType] || 'other',
entity_id: props.itemId,
entity_name: props.itemName || 'item'
})
} catch (error) { } catch (error) {
console.error('Error toggling favorite:', error) console.error('Error toggling favorite:', error)
} finally { } finally {

View File

@ -1,9 +1,48 @@
/** analyticsService — stub. Analytics via Supabase can be implemented in v3 */ import { supabase } from '@/supabase';
export const analyticsService = {
logEvent(_event: any) { export interface AnalyticsEvent {
// no-op event_name: string;
}, entity_type?: 'business' | 'shuttle' | 'coupon' | 'stop' | 'route' | 'taxi' | 'system' | 'other';
async getDashboardStats() { entity_id?: string; // The ID of the specific entity
return null entity_name?: string; // Optional name for easier querying
} screen_name?: string; // Optional screen name
properties?: Record<string, any>;
} }
export const analyticsService = {
/**
* Logs an action or event to the analytics_events table.
*/
async logEvent(event: AnalyticsEvent) {
try {
const { data: userData } = await supabase.auth.getUser();
const payload = {
event_name: event.event_name,
entity_type: event.entity_type,
entity_id: event.entity_id,
entity_name: event.entity_name,
properties: event.properties || {},
user_id: userData?.user ? userData.user.id : null
};
const { error } = await supabase
.from('analytics_events')
.insert(payload);
if (error) {
console.warn('Analytics logging failed:', error);
}
} catch (e) {
console.warn('Failed to dispatch analytics event:', e);
}
},
/**
* Gets aggregated statistics for the Dashboard.
* Can be fleshed out with RPCs later if calculations are too heavy.
*/
async getDashboardStats() {
return null;
}
};

View File

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

View File

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

View File

@ -50,19 +50,23 @@ function openCoupon(coupon: Coupon) {
showRedeemModal.value = true showRedeemModal.value = true
analyticsService.logEvent({ analyticsService.logEvent({
event_name: 'promo_view', event_name: 'promo_view',
item_id: coupon.title, entity_type: 'coupon',
properties: { coupon_id: coupon.id, business: coupon.business?.name } entity_id: coupon.id,
entity_name: coupon.title,
properties: { business: coupon.business?.name }
}) })
} }
function handleDirections() { function handleDirections() {
if (!selectedCoupon.value) return if (!selectedCoupon.value) return
analyticsService.logEvent({ analyticsService.logEvent({
event_name: 'promo_click', event_name: 'location_click',
item_id: 'directions_' + selectedCoupon.value.business?.name, entity_type: 'coupon',
entity_id: selectedCoupon.value.id,
entity_name: selectedCoupon.value.title,
properties: { 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') 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) { 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) router.push('/business/' + biz.id)
} }

View File

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

View File

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

View File

@ -142,17 +142,27 @@
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Ruta</th> <th>Shuttle</th>
<th>Conversión</th> <th>Interés (Vistas)</th>
<th>Ratio</th> <th>Reservas & Llamadas</th>
<th>Ratio de Conversión</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(data, id) in stats.shuttles" :key="id"> <tr v-for="(data, name) in stats.shuttles" :key="name">
<td class="id-cell">{{ id }}</td> <td class="id-cell">{{ name }}</td>
<td>{{ calculateConversion(data.views, data.contacts) }}%</td> <td>{{ data.views }}</td>
<td> <td>
<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> <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> </td>
</tr> </tr>
</tbody> </tbody>
@ -202,15 +212,29 @@
<tr> <tr>
<th>Negocio</th> <th>Negocio</th>
<th>Visitas</th> <th>Visitas</th>
<th>Interacciones (R/LL/M)</th>
<th>Cupones</th> <th>Cupones</th>
<th>Favoritos</th>
<th>Salud</th> <th>Salud</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(data, id) in stats.businesses" :key="id"> <tr v-for="(data, name) in stats.businesses" :key="name">
<td class="id-cell">{{ id }}</td> <td class="id-cell">{{ name }}</td>
<td>{{ data.views }}</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>{{ 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> <td>
<span class="status-pill" :class="getHealthClass(calculateConversion(data.views, data.promos))"> <span class="status-pill" :class="getHealthClass(calculateConversion(data.views, data.promos))">
{{ getHealthLabel(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 () => { onMounted(async () => {
try { try {
// Load all data in parallel
const [ const [
{ count: userCount }, { count: userCount },
{ data: shuttles }, { data: events }
{ data: routes },
{ data: businesses }
] = await Promise.all([ ] = await Promise.all([
supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true), supabase.from('users').select('*', { count: 'exact', head: true }).eq('is_active', true),
supabase.from('shuttles').select('id, route_name'), // In a production app with >1M rows we might use a group-by RPC
supabase.from('routes').select('id, name'), supabase.from('analytics_events').select('*')
supabase.from('businesses').select('id, name')
]) ])
const shuttleStats: any = {} 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 = {} const bizStats: any = {}
for (const b of (businesses || [])) { let total_promo_clicks = 0
bizStats[b.name || b.id] = { views: Math.floor(Math.random() * 60), promos: Math.floor(Math.random() * 10) } 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 = { stats.value = {
shuttles: shuttleStats, shuttles: shuttleStats,
businesses: bizStats, businesses: bizStats,
top_stops: [], top_stops: [], // Legacy logic can still go here if stops tracking is added
users: { users: {
registered_active: userCount || 0, registered_active: userCount || 0,
patterns: { registered: {}, guests: {} } patterns: { registered: {}, guests: {} }
}, },
summary: { summary: {
total_shuttle_contacts: Object.values(shuttleStats).reduce((a: any, v: any) => a + v.contacts, 0), total_shuttle_contacts,
total_promo_clicks: Object.values(bizStats).reduce((a: any, v: any) => a + v.promos, 0), total_promo_clicks,
total_biz_views: Object.values(bizStats).reduce((a: any, v: any) => a + v.views, 0) 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> </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}`" :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" 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" 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> <span class="material-icons">chat</span>
{{ t('shuttle.bookWhatsapp') }} {{ t('shuttle.bookWhatsapp') }}
@ -204,7 +204,7 @@ const getTripTypeLabel = (type: string) => {
<a v-if="shuttle.phone_number" <a v-if="shuttle.phone_number"
:href="`tel:${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" 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> <span class="material-icons">phone_in_talk</span>
{{ t('shuttle.callOperator') }} {{ t('shuttle.callOperator') }}

View File

@ -63,7 +63,9 @@ const getShiftsDisplay = (taxi: Taxi) => {
const handleCall = (taxi: Taxi) => { const handleCall = (taxi: Taxi) => {
analyticsService.logEvent({ analyticsService.logEvent({
event_name: 'taxi_click', event_name: 'taxi_click',
item_id: taxi.owner_name, entity_type: 'taxi',
entity_id: taxi.id,
entity_name: taxi.owner_name,
properties: { properties: {
action: 'call', action: 'call',
taxi_id: taxi.id, 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({ router.push({
name: 'ShuttleDetalle', name: 'ShuttleDetalle',
params: { id: shuttleId } params: { id: shuttleId }
@ -118,7 +124,7 @@ onUnmounted(() => {
:key="shuttle.id" :key="shuttle.id"
v-memo="[shuttle.id]" v-memo="[shuttle.id]"
class="shuttle-card-premium glass-effect" 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"> <div class="card-image-wrap">
<img <img