From 41a225d4608fd20aad7ffd1e067a83114ba6e4fb Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Fri, 17 Jan 2025 11:14:05 -0300 Subject: [PATCH] feat: Melhorando tracking com o Rudderstack --- .../analytics/RudderstackAnalytics.tsx | 83 ------ src/components/audio/AudioUploader.tsx | 3 +- src/components/auth/LoginForm.tsx | 5 +- src/components/home/HomePage.tsx | 17 +- src/components/ui/button.tsx | 17 +- src/components/ui/form.tsx | 47 ++++ src/components/ui/link.tsx | 41 +++ src/components/ui/plan-for-parents.tsx | 3 +- src/components/ui/plan-for-schools.tsx | 5 +- src/constants/analytics.ts | 188 +++++++++++++ src/hooks/useButtonTracking.ts | 34 ++- src/hooks/useErrorTracking.ts | 9 +- src/hooks/useFormTracking.ts | 16 +- src/hooks/useRudderstack.ts | 43 +-- src/hooks/useStudentTracking.ts | 14 +- src/lib/analytics/index.ts | 252 ++++++++++++++++++ src/main.tsx | 30 +-- src/types/analytics.ts | 54 ++++ 18 files changed, 671 insertions(+), 190 deletions(-) delete mode 100644 src/components/analytics/RudderstackAnalytics.tsx create mode 100644 src/components/ui/form.tsx create mode 100644 src/components/ui/link.tsx create mode 100644 src/constants/analytics.ts create mode 100644 src/lib/analytics/index.ts create mode 100644 src/types/analytics.ts diff --git a/src/components/analytics/RudderstackAnalytics.tsx b/src/components/analytics/RudderstackAnalytics.tsx deleted file mode 100644 index a196178..0000000 --- a/src/components/analytics/RudderstackAnalytics.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; - -interface RudderstackAnalyticsProps { - writeKey: string; - dataPlaneUrl: string; -} - -declare global { - interface Window { - rudderanalytics: any; - } -} - -export function RudderstackAnalytics({ writeKey, dataPlaneUrl }: RudderstackAnalyticsProps) { - React.useEffect(() => { - if (window.rudderanalytics?.loaded) { - return; - } - - // Inicializa o objeto rudderanalytics - window.rudderanalytics = window.rudderanalytics || []; - - // Define os métodos básicos - const methods = ['load', 'page', 'track', 'identify', 'alias', 'group', 'ready', 'reset']; - methods.forEach((method) => { - window.rudderanalytics[method] = function() { - window.rudderanalytics.push([method].concat(Array.prototype.slice.call(arguments))); - }; - }); - - // Carrega o script do Rudderstack - const loadScript = () => { - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'https://cdn.rudderlabs.com/v1.1/rudder-analytics.min.js'; - script.async = true; - script.crossOrigin = 'anonymous'; - - script.onload = () => { - window.rudderanalytics.load(writeKey, dataPlaneUrl, { - configUrl: 'https://api.rudderlabs.com', - destSDKBaseURL: 'https://cdn.rudderlabs.com/v1.1', - logLevel: 'ERROR', - secureCookie: true, - integrations: { All: true }, - loadIntegration: true, - sendAdblockPage: true, - sendAdblockPageOptions: { - integrations: { All: true } - } - }); - resolve(); - }; - - script.onerror = (error) => { - console.error('Erro ao carregar Rudderstack:', error); - reject(error); - }; - - document.head.appendChild(script); - }); - }; - - // Carrega o script e inicializa - loadScript() - .then(() => { - window.rudderanalytics.page(); - }) - .catch((error) => { - console.error('Falha ao inicializar Rudderstack:', error); - }); - - return () => { - const script = document.querySelector('script[src*="rudder-analytics.min.js"]'); - if (script && script.parentNode) { - script.parentNode.removeChild(script); - } - delete window.rudderanalytics; - }; - }, [writeKey, dataPlaneUrl]); - - return null; -} \ No newline at end of file diff --git a/src/components/audio/AudioUploader.tsx b/src/components/audio/AudioUploader.tsx index f402f97..1c9113d 100644 --- a/src/components/audio/AudioUploader.tsx +++ b/src/components/audio/AudioUploader.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { processAudio } from '../../services/audioService'; import { Button } from '../ui/button'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; interface AudioUploaderProps { storyId: string; @@ -59,7 +60,7 @@ export function AudioUploader({ className="cursor-pointer" trackingId="audio-upload-button" trackingProperties={{ - category: 'audio', + category: EVENT_CATEGORIES.AUDIO, action: 'upload_click', label: 'audio_uploader' }} diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 80d16cd..91aa9d5 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -7,6 +7,7 @@ import { useDataLayer } from '../../hooks/useDataLayer'; import { useFormTracking } from '../../hooks/useFormTracking'; import { Button } from '../ui/button'; import { useErrorTracking } from '../../hooks/useErrorTracking'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; interface LoginFormProps { userType: 'school' | 'teacher' | 'student'; @@ -224,7 +225,7 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps variant="primary" size="lg" trackingProperties={{ - category: 'auth', + category: EVENT_CATEGORIES.AUTH, action: 'login_attempt', label: `${userType}_login`, value: 1, @@ -252,7 +253,7 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps size="sm" onClick={onRegisterClick} trackingProperties={{ - category: 'auth', + category: EVENT_CATEGORIES.AUTH, action: 'register_click', label: userType, }} diff --git a/src/components/home/HomePage.tsx b/src/components/home/HomePage.tsx index 6a799fb..3ea2d25 100644 --- a/src/components/home/HomePage.tsx +++ b/src/components/home/HomePage.tsx @@ -16,6 +16,7 @@ import { ProcessStep } from '@/components/ui/process-step'; import { InfoCard } from '@/components/ui/info-card'; import { ComparisonSection } from '@/components/ui/comparison-section'; import { Button } from '@/components/ui/button'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; const navigation = [ { name: 'Início', href: '/' }, @@ -55,7 +56,7 @@ export function HomePage() { variant="ghost" trackingId="nav_login_button" trackingProperties={{ - category: 'navigation', + category: EVENT_CATEGORIES.NAVIGATION, action: 'click', label: 'login_dropdown' }} @@ -71,7 +72,7 @@ export function HomePage() { className="w-full text-left" trackingId="nav_school_login" trackingProperties={{ - category: 'navigation', + category: EVENT_CATEGORIES.NAVIGATION, action: 'click', label: 'school_login' }} @@ -84,7 +85,7 @@ export function HomePage() { className="w-full text-left" trackingId="nav_teacher_login" trackingProperties={{ - category: 'navigation', + category: EVENT_CATEGORIES.NAVIGATION, action: 'click', label: 'teacher_login' }} @@ -97,7 +98,7 @@ export function HomePage() { className="w-full text-left" trackingId="nav_student_login" trackingProperties={{ - category: 'navigation', + category: EVENT_CATEGORIES.NAVIGATION, action: 'click', label: 'student_login' }} @@ -113,7 +114,7 @@ export function HomePage() { variant="primary" trackingId="nav_register_button" trackingProperties={{ - category: 'navigation', + category: EVENT_CATEGORIES.NAVIGATION, action: 'click', label: 'register_school' }} @@ -149,7 +150,7 @@ export function HomePage() { className="gap-2" trackingId="hero_register_button" trackingProperties={{ - category: 'hero', + category: EVENT_CATEGORIES.HERO, action: 'click', label: 'start_free', position: 'hero_section' @@ -165,7 +166,7 @@ export function HomePage() { className="gap-2" trackingId="hero_demo_button" trackingProperties={{ - category: 'hero', + category: EVENT_CATEGORIES.HERO, action: 'click', label: 'watch_demo', position: 'hero_section' @@ -205,7 +206,7 @@ export function HomePage() { className="absolute inset-0 flex items-center justify-center bg-black/30 hover:bg-black/40 transition group" trackingId="hero_video_play" trackingProperties={{ - category: 'hero', + category: EVENT_CATEGORIES.HERO, action: 'click', label: 'play_demo_video', position: 'hero_video' diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 2b0ec48..e869cbf 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,20 +1,15 @@ import React from 'react'; import { useButtonTracking } from '../../hooks/useButtonTracking'; +import { ButtonTrackingOptions } from '../../types/analytics'; import { cn } from '../../lib/utils'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; interface ButtonProps extends React.ButtonHTMLAttributes { as?: 'button' | 'span'; trackingId: string; variant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link'; size?: 'sm' | 'md' | 'lg'; - trackingProperties?: { - label?: string; - value?: string | number; - action?: string; - category?: string; - position?: string; - [key: string]: any; - }; + trackingProperties?: ButtonTrackingOptions; } export function Button({ @@ -31,18 +26,18 @@ export function Button({ ...props }: ButtonProps) { const { trackButtonClick } = useButtonTracking({ - category: trackingProperties?.category || 'interaction' + category: EVENT_CATEGORIES.INTERACTION, + element_type: 'button', + ...trackingProperties }); const handleClick = (event: React.MouseEvent) => { - // Rastreia o clique trackButtonClick(trackingId, { variant, size, ...trackingProperties, }); - // Chama o onClick original se existir onClick?.(event); }; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..d781fd5 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { useFormTracking } from '../../hooks/useFormTracking'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; + +interface FormProps extends React.FormHTMLAttributes { + formId: string; + formName?: string; + trackingProperties?: { + category?: string; + [key: string]: unknown; + }; +} + +export function Form({ + formId, + formName = formId, + children, + trackingProperties, + onSubmit, + ...props +}: FormProps) { + const { trackFormStarted, trackFormSubmitted } = useFormTracking({ + formId, + formName, + category: EVENT_CATEGORIES.FORM, + ...trackingProperties + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + trackFormSubmitted(true); + onSubmit?.(e); + }; + + React.useEffect(() => { + trackFormStarted(); + }, [formId, trackFormStarted]); + + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/src/components/ui/link.tsx b/src/components/ui/link.tsx new file mode 100644 index 0000000..cea2370 --- /dev/null +++ b/src/components/ui/link.tsx @@ -0,0 +1,41 @@ +import { Link as RouterLink } from 'react-router-dom'; +import { useButtonTracking } from '../../hooks/useButtonTracking'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; +import { ButtonTrackingOptions } from '../../types/analytics'; + +interface LinkProps extends React.AnchorHTMLAttributes { + to: string; + trackingId?: string; + trackingProperties?: ButtonTrackingOptions; +} + +export function Link({ + to, + children, + trackingId, + trackingProperties, + ...props +}: LinkProps) { + const { trackButtonClick } = useButtonTracking({ + category: EVENT_CATEGORIES.INTERACTION, + element_type: 'link', + ...trackingProperties + }); + + const handleClick = (e: React.MouseEvent) => { + if (trackingId) { + trackButtonClick(trackingId); + } + props.onClick?.(e); + }; + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/components/ui/plan-for-parents.tsx b/src/components/ui/plan-for-parents.tsx index 1c303fa..f94e23c 100644 --- a/src/components/ui/plan-for-parents.tsx +++ b/src/components/ui/plan-for-parents.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { CheckCircle } from 'lucide-react'; import { Button } from './button'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; interface PlanProps { title: string; @@ -120,7 +121,7 @@ export function PlanForParents({ className="w-full" trackingId={`parent_plan_subscribe_${plan.title.toLowerCase().replace(/\s+/g, '_')}`} trackingProperties={{ - category: 'pricing', + category: EVENT_CATEGORIES.PRICING, action: 'click', label: plan.title, value: parseFloat(plan.price.replace(/[.,]/g, '')), diff --git a/src/components/ui/plan-for-schools.tsx b/src/components/ui/plan-for-schools.tsx index bc5749b..80d4aea 100644 --- a/src/components/ui/plan-for-schools.tsx +++ b/src/components/ui/plan-for-schools.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { CheckCircle } from 'lucide-react'; import { Button } from './button'; +import { EVENT_CATEGORIES } from '../../constants/analytics'; interface PlanProps { title: string; @@ -109,7 +110,7 @@ export function PlanForSchools({ className="w-full" trackingId={`plan_subscribe_${plan.title.toLowerCase().replace(/\s+/g, '_')}`} trackingProperties={{ - category: 'pricing', + category: EVENT_CATEGORIES.PRICING, action: 'click', label: plan.title, value: parseFloat(plan.price.replace(/[.,]/g, '')), @@ -138,7 +139,7 @@ export function PlanForSchools({ className="gap-2" trackingId="pricing_contact_button" trackingProperties={{ - category: 'pricing', + category: EVENT_CATEGORIES.PRICING, action: 'click', label: 'contact_sales', position: 'footer', diff --git a/src/constants/analytics.ts b/src/constants/analytics.ts new file mode 100644 index 0000000..0ea5b48 --- /dev/null +++ b/src/constants/analytics.ts @@ -0,0 +1,188 @@ +// Eventos de Página +export const PAGE_EVENTS = { + VIEW: 'page_viewed', + SCROLL: 'page_scrolled', + EXIT: 'page_exited', +} as const; + +// Eventos de Usuário +export const USER_EVENTS = { + SIGN_UP: 'user_signed_up', + SIGN_IN: 'user_signed_in', + SIGN_OUT: 'user_signed_out', + UPDATE_PROFILE: 'user_updated_profile', + DELETE_ACCOUNT: 'user_deleted_account', +} as const; + +// Eventos de Histórias +export const STORY_EVENTS = { + GENERATE: 'story_generated', + VIEW: 'story_viewed', + SHARE: 'story_shared', + LIKE: 'story_liked', + SAVE: 'story_saved', + PRINT: 'story_printed', + AUDIO_RECORD: 'story_audio_recorded', + AUDIO_PLAY: 'story_audio_played', +} as const; + +// Eventos de Exercícios +export const EXERCISE_EVENTS = { + START: 'exercise_started', + COMPLETE: 'exercise_completed', + SKIP: 'exercise_skipped', + RETRY: 'exercise_retried', + SUBMIT_ANSWER: 'exercise_answer_submitted', +} as const; + +// Eventos de Interação +export const INTERACTION_EVENTS = { + BUTTON_CLICK: 'button_clicked', + LINK_CLICK: 'link_clicked', + FORM_START: 'form_started', + FORM_SUBMIT: 'form_submitted', + FORM_ERROR: 'form_error_occurred', + MODAL_OPEN: 'modal_opened', + MODAL_CLOSE: 'modal_closed', + MENU_OPEN: 'menu_opened', + MENU_CLOSE: 'menu_closed', +} as const; + +// Eventos de Erro +export const ERROR_EVENTS = { + API: 'api_error_occurred', + CLIENT: 'client_error_occurred', + VALIDATION: 'validation_error_occurred', + BOUNDARY: 'error_boundary_triggered', +} as const; + +// Eventos de Assinatura +export const SUBSCRIPTION_EVENTS = { + VIEW_PLANS: 'subscription_plans_viewed', + SELECT_PLAN: 'subscription_plan_selected', + START_CHECKOUT: 'subscription_checkout_started', + COMPLETE_CHECKOUT: 'subscription_checkout_completed', + CANCEL: 'subscription_cancelled', +} as const; + +// Categorias de Eventos +export const EVENT_CATEGORIES = { + PAGE: 'page', + USER: 'user', + STORY: 'story', + EXERCISE: 'exercise', + INTERACTION: 'interaction', + ERROR: 'error', + SUBSCRIPTION: 'subscription', + AUTH: 'auth', + NAVIGATION: 'navigation', + HERO: 'hero', + PRICING: 'pricing', + AUDIO: 'audio', + FORM: 'form' +} as const; + +// Propriedades Comuns +export const EVENT_PROPERTIES = { + // Propriedades de Página + PAGE: { + URL: 'page_url', + TITLE: 'page_title', + REFERRER: 'page_referrer', + PATH: 'page_path', + }, + + // Propriedades de Usuário + USER: { + ID: 'user_id', + TYPE: 'user_type', + ROLE: 'user_role', + SCHOOL: 'school_id', + CLASS: 'class_id', + }, + + // Propriedades de Interação + INTERACTION: { + TYPE: 'interaction_type', + TARGET: 'interaction_target', + LOCATION: 'interaction_location', + VALUE: 'interaction_value', + }, + + // Propriedades de Elemento + ELEMENT: { + ID: 'element_id', + TYPE: 'element_type', + NAME: 'element_name', + VALUE: 'element_value', + TEXT: 'element_text', + VARIANT: 'element_variant', + SIZE: 'element_size', + POSITION: 'element_position', + }, + + // Propriedades de Erro + ERROR: { + TYPE: 'error_type', + MESSAGE: 'error_message', + CODE: 'error_code', + STACK: 'error_stack', + }, + + // Propriedades de Tempo + TIMING: { + STARTED_AT: 'started_at', + COMPLETED_AT: 'completed_at', + DURATION: 'duration', + TIMESTAMP: 'timestamp', + }, + + // Propriedades de Formulário + FORM: { + ID: 'form_id', + NAME: 'form_name', + TYPE: 'form_type', + STEP: 'form_step', + STATUS: 'form_status', + FIELDS: 'form_fields', + }, +} as const; + +// Valores de Propriedades +export const PROPERTY_VALUES = { + USER_TYPES: { + STUDENT: 'student', + TEACHER: 'teacher', + ADMIN: 'admin', + PARENT: 'parent', + }, + + INTERACTION_TYPES: { + CLICK: 'click', + HOVER: 'hover', + SCROLL: 'scroll', + SUBMIT: 'submit', + INPUT: 'input', + }, + + ERROR_TYPES: { + API: 'api', + CLIENT: 'client', + VALIDATION: 'validation', + BOUNDARY: 'boundary', + }, + + ELEMENT_SIZES: { + SMALL: 'sm', + MEDIUM: 'md', + LARGE: 'lg', + }, + + ELEMENT_VARIANTS: { + PRIMARY: 'primary', + SECONDARY: 'secondary', + OUTLINE: 'outline', + GHOST: 'ghost', + LINK: 'link', + }, +} as const; \ No newline at end of file diff --git a/src/hooks/useButtonTracking.ts b/src/hooks/useButtonTracking.ts index 703a559..01a3974 100644 --- a/src/hooks/useButtonTracking.ts +++ b/src/hooks/useButtonTracking.ts @@ -1,26 +1,38 @@ -import { useRudderstack } from './useRudderstack'; +import { analytics } from '../lib/analytics'; interface ButtonTrackingOptions { category?: string; location?: string; } +interface ButtonTrackingProperties { + label?: string; + value?: string | number; + action?: string; + variant?: string; + position?: string; + // Propriedades específicas de tracking + flow?: string; + section?: string; + target?: string; + source?: string; + element_type?: string; + element_id?: string; + element_name?: string; + element_value?: string | number; + element_text?: string; + element_class?: string; + element_attributes?: Record; +} + export function useButtonTracking(options: ButtonTrackingOptions = {}) { - const { track } = useRudderstack(); const { category = 'interaction', location = window.location.pathname } = options; const trackButtonClick = ( buttonId: string, - properties?: { - label?: string; - value?: string | number; - action?: string; - variant?: string; - position?: string; - [key: string]: any; - } + properties?: ButtonTrackingProperties ) => { - track('button_clicked', { + analytics.track('button_clicked', { button_id: buttonId, category, location, diff --git a/src/hooks/useErrorTracking.ts b/src/hooks/useErrorTracking.ts index e0fbbaf..136038a 100644 --- a/src/hooks/useErrorTracking.ts +++ b/src/hooks/useErrorTracking.ts @@ -1,5 +1,5 @@ import * as Sentry from '@sentry/react'; -import { useRudderstack } from './useRudderstack'; +import { analytics } from '../lib/analytics'; interface ErrorTrackingOptions { category?: string; @@ -8,7 +8,6 @@ interface ErrorTrackingOptions { } export function useErrorTracking(options: ErrorTrackingOptions = {}) { - const { track } = useRudderstack(); const { category = 'error', userId, userEmail } = options; const trackError = ( @@ -34,7 +33,7 @@ export function useErrorTracking(options: ErrorTrackingOptions = {}) { }); // 2. Rastreia no Rudderstack (para analytics) - track('error_occurred', { + analytics.track('error_occurred', { category, error_name: error.name, error_message: error.message, @@ -64,7 +63,7 @@ export function useErrorTracking(options: ErrorTrackingOptions = {}) { }); // 2. Rastreia no Rudderstack - track('error_boundary_triggered', { + analytics.track('error_boundary_triggered', { category, error_name: error.name, error_message: error.message, @@ -93,7 +92,7 @@ export function useErrorTracking(options: ErrorTrackingOptions = {}) { }); // 2. Rastreia no Rudderstack - track('api_error_occurred', { + analytics.track('api_error_occurred', { category, endpoint, method, diff --git a/src/hooks/useFormTracking.ts b/src/hooks/useFormTracking.ts index da0bc16..bf7cc89 100644 --- a/src/hooks/useFormTracking.ts +++ b/src/hooks/useFormTracking.ts @@ -1,4 +1,4 @@ -import { useRudderstack } from './useRudderstack'; +import { analytics } from '../lib/analytics'; interface FormTrackingOptions { formId: string; @@ -7,10 +7,8 @@ interface FormTrackingOptions { } export function useFormTracking({ formId, formName, category = 'form' }: FormTrackingOptions) { - const { track } = useRudderstack(); - const trackFormStarted = () => { - track('form_started', { + analytics.track('form_started', { form_id: formId, form_name: formName, category, @@ -18,7 +16,7 @@ export function useFormTracking({ formId, formName, category = 'form' }: FormTra }; const trackFormStepCompleted = (step: string, isValid: boolean) => { - track('form_step_completed', { + analytics.track('form_step_completed', { form_id: formId, form_name: formName, category, @@ -28,7 +26,7 @@ export function useFormTracking({ formId, formName, category = 'form' }: FormTra }; const trackFormSubmitted = (isValid: boolean, fields?: Record) => { - track('form_submitted', { + analytics.track('form_submitted', { form_id: formId, form_name: formName, category, @@ -38,7 +36,7 @@ export function useFormTracking({ formId, formName, category = 'form' }: FormTra }; const trackFormError = (errorType: string, errorMessage: string, field?: string) => { - track('form_error', { + analytics.track('form_error', { form_id: formId, form_name: formName, category, @@ -49,7 +47,7 @@ export function useFormTracking({ formId, formName, category = 'form' }: FormTra }; const trackFormAbandoned = (step?: string) => { - track('form_abandoned', { + analytics.track('form_abandoned', { form_id: formId, form_name: formName, category, @@ -62,7 +60,7 @@ export function useFormTracking({ formId, formName, category = 'form' }: FormTra interactionType: 'focus' | 'blur' | 'change', value?: string ) => { - track('form_field_interaction', { + analytics.track('form_field_interaction', { form_id: formId, form_name: formName, category, diff --git a/src/hooks/useRudderstack.ts b/src/hooks/useRudderstack.ts index 6cfe385..89e30b3 100644 --- a/src/hooks/useRudderstack.ts +++ b/src/hooks/useRudderstack.ts @@ -1,47 +1,25 @@ -interface RudderstackEvent { - event: string; - properties?: Record; -} - -interface UserTraits { - [key: string]: any; -} +import { analytics } from '../lib/analytics'; +import { UserTraits } from '../types/analytics'; export function useRudderstack() { - const track = (eventName: string, properties?: Record) => { - if (window.rudderanalytics) { - window.rudderanalytics.track(eventName, properties); - } + const track = (eventName: string, properties?: Record) => { + analytics.track(eventName, properties); }; - const page = (name?: string, properties?: Record) => { - if (window.rudderanalytics) { - window.rudderanalytics.page(name, properties); - } + const page = (name?: string, properties?: Record) => { + analytics.page(name, properties); }; const identify = (userId: string, traits?: UserTraits) => { - if (window.rudderanalytics) { - window.rudderanalytics.identify(userId, traits); - } + analytics.identify({ id: userId, user_metadata: traits } as any); }; - const group = (groupId: string, traits?: Record) => { - if (window.rudderanalytics) { - window.rudderanalytics.group(groupId, traits); - } - }; - - const alias = (newUserId: string) => { - if (window.rudderanalytics) { - window.rudderanalytics.alias(newUserId); - } + const group = (groupId: string, traits?: Record) => { + analytics.group(groupId, traits); }; const reset = () => { - if (window.rudderanalytics) { - window.rudderanalytics.reset(); - } + analytics.reset(); }; return { @@ -49,7 +27,6 @@ export function useRudderstack() { page, identify, group, - alias, reset, }; } \ No newline at end of file diff --git a/src/hooks/useStudentTracking.ts b/src/hooks/useStudentTracking.ts index 8bc1dc2..19ba876 100644 --- a/src/hooks/useStudentTracking.ts +++ b/src/hooks/useStudentTracking.ts @@ -1,4 +1,4 @@ -import { useRudderstack } from './useRudderstack'; +import { analytics } from '../lib/analytics'; interface StoryGeneratedProps { story_id: string; @@ -47,38 +47,36 @@ interface InterestActionProps { } export function useStudentTracking() { - const { track } = useRudderstack(); - const trackStoryGenerated = (properties: StoryGeneratedProps) => { - track('story_generated', { + analytics.track('story_generated', { ...properties, timestamp: new Date().toISOString() }); }; const trackAudioRecorded = (properties: AudioRecordedProps) => { - track('audio_recorded', { + analytics.track('audio_recorded', { ...properties, timestamp: new Date().toISOString() }); }; const trackExerciseCompleted = (properties: ExerciseCompletedProps) => { - track('exercise_completed', { + analytics.track('exercise_completed', { ...properties, timestamp: new Date().toISOString() }); }; const trackInterestAdded = (properties: InterestActionProps) => { - track('interest_added', { + analytics.track('interest_added', { ...properties, timestamp: new Date().toISOString() }); }; const trackInterestRemoved = (properties: InterestActionProps) => { - track('interest_removed', { + analytics.track('interest_removed', { ...properties, timestamp: new Date().toISOString() }); diff --git a/src/lib/analytics/index.ts b/src/lib/analytics/index.ts new file mode 100644 index 0000000..0ef34dd --- /dev/null +++ b/src/lib/analytics/index.ts @@ -0,0 +1,252 @@ +import { User } from '@supabase/supabase-js'; +import * as Sentry from '@sentry/react'; +import { EVENT_PROPERTIES } from '../../constants/analytics'; +import { BaseEventProperties, UserTraits } from '../../types/analytics'; + +type RudderEvent = { + type: 'identify' | 'track' | 'page' | 'group' | 'reset'; + args: unknown[]; +}; + +interface RudderAnalytics { + identify: (userId: string, traits?: Record) => void; + track: (eventName: string, properties?: Record) => void; + page: (name?: string, properties?: Record) => void; + group: (groupId: string, traits?: Record) => void; + reset: () => void; + load: (writeKey: string, dataPlaneUrl: string, options?: Record) => void; +} + +declare global { + interface Window { + rudderanalytics: RudderAnalytics | undefined; + } +} + +interface AnalyticsOptions { + debug?: boolean; + writeKey?: string; + dataPlaneUrl?: string; +} + +type AnalyticsError = Error | { message: string } | unknown; + +export class Analytics { + private debug: boolean; + private user: User | null = null; + private writeKey?: string; + private dataPlaneUrl?: string; + private initialized = false; + private eventQueue: RudderEvent[] = []; + private initPromise: Promise | null = null; + + constructor(options: AnalyticsOptions = {}) { + this.debug = options.debug || false; + this.writeKey = options.writeKey; + this.dataPlaneUrl = options.dataPlaneUrl; + } + + // Inicializa o Rudderstack + init(options?: { writeKey?: string; dataPlaneUrl?: string }): Promise { + if (this.initPromise) return this.initPromise; + + this.initPromise = new Promise((resolve, reject) => { + const writeKey = options?.writeKey || this.writeKey || import.meta.env.VITE_RUDDERSTACK_WRITE_KEY; + const dataPlaneUrl = options?.dataPlaneUrl || this.dataPlaneUrl || import.meta.env.VITE_RUDDERSTACK_DATA_PLANE_URL; + + if (!writeKey || !dataPlaneUrl) { + const error = new Error('Missing Rudderstack configuration'); + this.handleError('init', error); + reject(error); + return; + } + + try { + const script = document.createElement('script'); + script.src = 'https://cdn.rudderlabs.com/v1.1/rudder-analytics.min.js'; + script.async = true; + + script.onload = () => { + if (window.rudderanalytics) { + window.rudderanalytics.load(writeKey, dataPlaneUrl, { + configUrl: 'https://api.rudderlabs.com', + logLevel: this.debug ? 'DEBUG' : 'ERROR', + }); + + // Espera um pequeno intervalo para garantir que o Rudderstack está pronto + setTimeout(() => { + this.initialized = true; + this.processEventQueue(); + if (this.debug) { + console.log('Rudderstack initialized successfully'); + } + resolve(); + }, 100); + } else { + const error = new Error('Failed to load Rudderstack'); + this.handleError('init', error); + reject(error); + } + }; + + script.onerror = (error) => { + this.handleError('init', error); + reject(error); + }; + + document.head.appendChild(script); + } catch (error) { + this.handleError('init', error); + reject(error); + } + }); + + return this.initPromise; + } + + private processEventQueue() { + while (this.eventQueue.length > 0) { + const event = this.eventQueue.shift(); + if (!event) continue; + + const analytics = window.rudderanalytics; + if (!analytics) continue; + + try { + switch (event.type) { + case 'identify': + analytics.identify(...(event.args as [string, Record])); + break; + case 'track': + analytics.track(...(event.args as [string, Record])); + break; + case 'page': + analytics.page(...(event.args as [string, Record])); + break; + case 'group': + analytics.group(...(event.args as [string, Record])); + break; + case 'reset': + analytics.reset(); + break; + } + } catch (error) { + this.handleError(event.type, error); + } + } + } + + private queueEvent(type: RudderEvent['type'], args: unknown[]) { + if (this.initialized && window.rudderanalytics) { + const analytics = window.rudderanalytics; + try { + switch (type) { + case 'identify': + analytics.identify(...(args as [string, Record])); + break; + case 'track': + analytics.track(...(args as [string, Record])); + break; + case 'page': + analytics.page(...(args as [string, Record])); + break; + case 'group': + analytics.group(...(args as [string, Record])); + break; + case 'reset': + analytics.reset(); + break; + } + } catch (error) { + this.handleError(type, error); + } + } else { + this.eventQueue.push({ type, args }); + } + } + + // Método para identificar usuário + identify(user: User | null) { + this.user = user; + if (!user) return; + + const traits: UserTraits = { + email: user.email, + name: user.user_metadata?.name, + role: user.user_metadata?.role, + school_id: user.user_metadata?.school_id, + class_id: user.user_metadata?.class_id, + created_at: user.created_at, + updated_at: user.updated_at + }; + + this.queueEvent('identify', [user.id, traits]); + } + + // Método principal de tracking + track(eventName: string, properties?: Record) { + const enrichedProperties = this.enrichEventProperties(properties); + this.queueEvent('track', [eventName, enrichedProperties]); + } + + // Método para tracking de página + page(name?: string, properties?: Record) { + const enrichedProperties = this.enrichEventProperties(properties); + this.queueEvent('page', [name, enrichedProperties]); + } + + // Método para agrupar por escola + group(schoolId: string, properties?: Record) { + const enrichedProperties = this.enrichEventProperties(properties); + this.queueEvent('group', [schoolId, enrichedProperties]); + } + + // Reset do usuário + reset() { + this.user = null; + this.queueEvent('reset', []); + } + + // Enriquece as propriedades com dados padrão + private enrichEventProperties(properties?: Record): BaseEventProperties { + const baseProperties: BaseEventProperties = { + [EVENT_PROPERTIES.TIMING.TIMESTAMP]: new Date().toISOString(), + [EVENT_PROPERTIES.PAGE.URL]: window.location.href, + [EVENT_PROPERTIES.PAGE.TITLE]: document.title, + [EVENT_PROPERTIES.PAGE.PATH]: window.location.pathname, + environment: import.meta.env.MODE, + }; + + // Adiciona informações do usuário se disponível + if (this.user) { + baseProperties[EVENT_PROPERTIES.USER.ID] = this.user.id; + baseProperties[EVENT_PROPERTIES.USER.ROLE] = this.user.user_metadata?.role; + baseProperties[EVENT_PROPERTIES.USER.SCHOOL] = this.user.user_metadata?.school_id; + baseProperties[EVENT_PROPERTIES.USER.CLASS] = this.user.user_metadata?.class_id; + } + + return { + ...baseProperties, + ...properties + }; + } + + // Tratamento de erros + private handleError(method: string, error: AnalyticsError) { + if (this.debug) { + console.error(`Analytics Error (${method}):`, error); + } + + // Captura o erro no Sentry + Sentry.captureException(error, { + tags: { + analytics_method: method + } + }); + } +} + +// Instância global +export const analytics = new Analytics({ + debug: import.meta.env.DEV +}); \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index fa08b25..8b326c5 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,16 +5,12 @@ import * as Sentry from "@sentry/react"; import { router } from './routes'; import { Toaster } from './components/ui/toaster'; import { GoogleTagManager } from './components/analytics/GoogleTagManager'; -import { RudderstackAnalytics } from './components/analytics/RudderstackAnalytics'; +import { analytics } from './lib/analytics'; import './index.css'; // GTM ID - Substitua pelo seu ID real do GTM const GTM_ID = import.meta.env.VITE_GTM_ID; -// Rudderstack Config -const RUDDERSTACK_WRITE_KEY = import.meta.env.VITE_RUDDERSTACK_WRITE_KEY; -const RUDDERSTACK_DATA_PLANE_URL = import.meta.env.VITE_RUDDERSTACK_DATA_PLANE_URL; - // Inicialização do Sentry Sentry.init({ dsn: "https://6c15876055bf4a860c1b63a8e4e7ca65@o544400.ingest.us.sentry.io/4508626073092096", @@ -54,14 +50,16 @@ if (!rootElement) throw new Error('Failed to find the root element'); const root = createRoot(rootElement); -root.render( - - - - - - -); \ No newline at end of file +// Inicializa o analytics e renderiza o app +analytics.init().catch((error) => { + console.error('Failed to initialize analytics:', error); + // Continua renderizando mesmo se o analytics falhar +}).finally(() => { + root.render( + + + + + + ); +}); \ No newline at end of file diff --git a/src/types/analytics.ts b/src/types/analytics.ts new file mode 100644 index 0000000..d538ddb --- /dev/null +++ b/src/types/analytics.ts @@ -0,0 +1,54 @@ +import { EVENT_CATEGORIES, EVENT_PROPERTIES, PROPERTY_VALUES } from '../constants/analytics'; + +export type EventCategory = typeof EVENT_CATEGORIES[keyof typeof EVENT_CATEGORIES]; +export type PropertyName = typeof EVENT_PROPERTIES[keyof typeof EVENT_PROPERTIES]; +export type UserType = typeof PROPERTY_VALUES.USER_TYPES[keyof typeof PROPERTY_VALUES.USER_TYPES]; +export type InteractionType = typeof PROPERTY_VALUES.INTERACTION_TYPES[keyof typeof PROPERTY_VALUES.INTERACTION_TYPES]; +export type ErrorType = typeof PROPERTY_VALUES.ERROR_TYPES[keyof typeof PROPERTY_VALUES.ERROR_TYPES]; +export type ElementSize = typeof PROPERTY_VALUES.ELEMENT_SIZES[keyof typeof PROPERTY_VALUES.ELEMENT_SIZES]; +export type ElementVariant = typeof PROPERTY_VALUES.ELEMENT_VARIANTS[keyof typeof PROPERTY_VALUES.ELEMENT_VARIANTS]; + +export interface BaseEventProperties { + category?: EventCategory; + action?: string; + label?: string; + value?: number; + interaction_type?: InteractionType; + error_type?: ErrorType; + user_type?: UserType; + [key: string]: unknown; +} + +export interface ButtonTrackingOptions { + category?: EventCategory; + element_type?: string; + variant?: ElementVariant; + size?: ElementSize; + position?: string; + section?: string; + [key: string]: unknown; +} + +export interface FormTrackingOptions { + category?: EventCategory; + form_id?: string; + form_name?: string; + form_type?: string; + form_step?: string; + form_status?: string; + fields?: Record; + [key: string]: unknown; +} + +export interface UserTraits { + id?: string; + email?: string; + name?: string; + type?: UserType; + role?: string; + school_id?: string; + class_id?: string; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} \ No newline at end of file