From 6e9d847c7752ceb2d36ea76e85db5b2a7408e1d9 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Sat, 11 Jan 2025 14:45:47 -0300 Subject: [PATCH] Implementando Rudderstack --- netlify.toml | 9 +- src/components/analytics/PageTracker.tsx | 20 +++++ .../analytics/RudderstackAnalytics.tsx | 62 ++++++++++++++ src/components/auth/LoginForm.tsx | 82 +++++++++++++++--- src/components/ui/button.tsx | 77 +++++++++++++---- src/hooks/useButtonTracking.ts | 50 +++++++++++ src/hooks/useFormTracking.ts | 83 +++++++++++++++++++ src/hooks/useRudderstack.ts | 55 ++++++++++++ src/main.tsx | 9 ++ src/routes.tsx | 12 ++- 10 files changed, 428 insertions(+), 31 deletions(-) create mode 100644 src/components/analytics/PageTracker.tsx create mode 100644 src/components/analytics/RudderstackAnalytics.tsx create mode 100644 src/hooks/useButtonTracking.ts create mode 100644 src/hooks/useFormTracking.ts create mode 100644 src/hooks/useRudderstack.ts diff --git a/netlify.toml b/netlify.toml index 1e2f368..69301c0 100644 --- a/netlify.toml +++ b/netlify.toml @@ -23,13 +23,12 @@ Referrer-Policy = "strict-origin-when-cross-origin" Content-Security-Policy = """ default-src 'self'; - connect-src 'self' https://bsjlbnyslxzsdwxvkaap.supabase.co wss://bsjlbnyslxzsdwxvkaap.supabase.co *.sentry.io *.ingest.sentry.io; - img-src 'self' data: https: blob:; - script-src 'self' 'unsafe-inline' 'unsafe-eval' https://bsjlbnyslxzsdwxvkaap.supabase.co *.sentry-cdn.com; + script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.rudderlabs.com https://www.googletagmanager.com; + connect-src 'self' https://*.rudderlabs.com https://*.ingest.sentry.io https://*.supabase.co https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; - frame-src 'self' https://bsjlbnyslxzsdwxvkaap.supabase.co; + img-src 'self' data: https:; font-src 'self' data:; - media-src 'self' https://bsjlbnyslxzsdwxvkaap.supabase.co; + frame-src 'self'; worker-src 'self' blob:; """ Access-Control-Allow-Origin = "https://historiasmagicas.netlify.app" diff --git a/src/components/analytics/PageTracker.tsx b/src/components/analytics/PageTracker.tsx new file mode 100644 index 0000000..69741d7 --- /dev/null +++ b/src/components/analytics/PageTracker.tsx @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useRudderstack } from '../../hooks/useRudderstack'; +import { useLocation } from 'react-router-dom'; + +export function PageTracker() { + const location = useLocation(); + const { track } = useRudderstack(); + + useEffect(() => { + track('page_viewed', { + path: location.pathname, + url: window.location.href, + search: location.search, + title: document.title, + referrer: document.referrer, + }); + }, [location, track]); + + return null; +} \ No newline at end of file diff --git a/src/components/analytics/RudderstackAnalytics.tsx b/src/components/analytics/RudderstackAnalytics.tsx new file mode 100644 index 0000000..1f7f076 --- /dev/null +++ b/src/components/analytics/RudderstackAnalytics.tsx @@ -0,0 +1,62 @@ +import React from 'react'; + +interface RudderstackAnalyticsProps { + writeKey: string; + dataPlaneUrl: string; +} + +declare global { + interface Window { + rudderanalytics: any; + RudderSnippetVersion: string; + rudderAnalyticsBuildType: string; + rudderAnalyticsMount: () => void; + rudderAnalyticsAddScript: (url: string, attr?: string, value?: string) => void; + } +} + +export function RudderstackAnalytics({ writeKey, dataPlaneUrl }: RudderstackAnalyticsProps) { + React.useEffect(() => { + // Verifica se o Rudderstack já está carregado + if (window.rudderanalytics?.loaded) { + return; + } + + const script = document.createElement('script'); + script.innerHTML = ` + !function(){"use strict";window.RudderSnippetVersion="3.0.32";var e="rudderanalytics";window[e]||(window[e]=[]); + var rudderanalytics=window[e];if(Array.isArray(rudderanalytics)){ + if(rudderanalytics.loaded){ + console.warn("RudderStack JavaScript SDK snippet included more than once."); + return; + } + rudderanalytics.loaded=true; + window.rudderAnalyticsBuildType="legacy";var sdkBaseUrl="https://cdn.rudderlabs.com/v3";var sdkName="rsa.min.js"; + var scriptLoadingMode="async"; + var r=["setDefaultInstanceKey","load","ready","page","track","identify","alias","group","reset","setAnonymousId","startSession","endSession","consent"]; + for(var n=0;n{};test({prop=[]}={}){return prop?(prop?.property??[...prop]):import("");}}'), + window.rudderAnalyticsBuildType="modern"}catch(o){}var d=document.head||document.getElementsByTagName("head")[0]; + var i=document.body||document.getElementsByTagName("body")[0];window.rudderAnalyticsAddScript=function(e,r,n){ + var t=document.createElement("script");t.src=e,t.setAttribute("data-loader","RS_JS_SDK"),r&&n&&t.setAttribute(r,n), + "async"===scriptLoadingMode?t.async=true:"defer"===scriptLoadingMode&&(t.defer=true), + d?d.insertBefore(t,d.firstChild):i.insertBefore(t,i.firstChild)},window.rudderAnalyticsMount=function(){!function(){ + if("undefined"==typeof globalThis){var e;var r=function getGlobal(){ + return"undefined"!=typeof self?self:"undefined"!=typeof window?window:null}();r&&Object.defineProperty(r,"globalThis",{ + value:r,configurable:true})} + }(),window.rudderAnalyticsAddScript("".concat(sdkBaseUrl,"/").concat(window.rudderAnalyticsBuildType,"/").concat(sdkName),"data-rsa-write-key","${writeKey}") + }, + "undefined"==typeof Promise||"undefined"==typeof globalThis?window.rudderAnalyticsAddScript("https://polyfill-fastly.io/v3/polyfill.min.js?version=3.111.0&features=Symbol%2CPromise&callback=rudderAnalyticsMount"):window.rudderAnalyticsMount(); + var loadOptions={};rudderanalytics.load("${writeKey}","${dataPlaneUrl}",loadOptions)}}}(); + `; + document.head.appendChild(script); + + return () => { + document.head.removeChild(script); + }; + }, [writeKey, dataPlaneUrl]); + + return null; +} \ No newline at end of file diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 397fe75..0d5424b 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -1,9 +1,11 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { LogIn, Eye, EyeOff, School, GraduationCap, User } from 'lucide-react'; import { useAuth } from '../../hooks/useAuth'; import { useNavigate } from 'react-router-dom'; import { supabase } from '../../lib/supabase'; import { useDataLayer } from '../../hooks/useDataLayer'; +import { useFormTracking } from '../../hooks/useFormTracking'; +import { Button } from '../ui/button'; interface LoginFormProps { userType: 'school' | 'teacher' | 'student'; @@ -32,6 +34,15 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps const { signIn } = useAuth(); const navigate = useNavigate(); const { trackEvent } = useDataLayer(); + const formTracking = useFormTracking({ + formId: 'login-form', + formName: `${userType}-login`, + category: 'auth' + }); + + useEffect(() => { + formTracking.trackFormStarted(); + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -48,9 +59,13 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps console.log('Resposta do Supabase:', { data, error }); - if (error) throw error; + if (error) { + formTracking.trackFormError('auth_error', error.message); + throw error; + } if (!data.user) { + formTracking.trackFormError('user_not_found', 'Usuário não encontrado'); throw new Error('Usuário não encontrado'); } @@ -60,9 +75,15 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps console.log('Role atual:', userRole); if (userRole !== userType) { + formTracking.trackFormError('invalid_role', `Este não é um login de ${userTypeLabels[userType]}`); throw new Error(`Este não é um login de ${userTypeLabels[userType]}`); } + formTracking.trackFormSubmitted(true, { + user_type: userType, + user_id: data.user.id + }); + switch (userType) { case 'school': navigate('/dashboard'); @@ -85,12 +106,30 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps } else { setError('Email ou senha incorretos'); } + formTracking.trackFormSubmitted(false, { + error_type: err instanceof Error ? 'validation_error' : 'unknown_error', + error_message: err instanceof Error ? err.message : 'Email ou senha incorretos' + }); trackEvent('auth', 'login_error', err.message); } finally { setLoading(false); } }; + const handleFieldChange = (field: string, value: string) => { + formTracking.trackFieldInteraction(field, 'change'); + if (field === 'email') setEmail(value); + if (field === 'password') setPassword(value); + }; + + const handleFieldFocus = (field: string) => { + formTracking.trackFieldInteraction(field, 'focus'); + }; + + const handleFieldBlur = (field: string) => { + formTracking.trackFieldInteraction(field, 'blur'); + }; + return (
@@ -123,7 +162,9 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps type="email" required value={email} - onChange={(e) => setEmail(e.target.value)} + onChange={(e) => handleFieldChange('email', e.target.value)} + onFocus={() => handleFieldFocus('email')} + onBlur={() => handleFieldBlur('email')} className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500" />
@@ -138,7 +179,9 @@ export function LoginForm({ userType, onLogin, onRegisterClick }: LoginFormProps type={showPassword ? 'text' : 'password'} required value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => handleFieldChange('password', e.target.value)} + onFocus={() => handleFieldFocus('password')} + onBlur={() => handleFieldBlur('password')} className="block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 pr-10" />
- + {onRegisterClick && (

Ainda não tem uma conta?{' '} - +

)} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 82b482e..2b0ec48 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,27 +1,76 @@ import React from 'react'; +import { useButtonTracking } from '../../hooks/useButtonTracking'; +import { cn } from '../../lib/utils'; interface ButtonProps extends React.ButtonHTMLAttributes { as?: 'button' | 'span'; - children: React.ReactNode; + 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; + }; } -export function Button({ +export function Button({ as: Component = 'button', - className = '', children, - ...props -}: ButtonProps): JSX.Element { + className = '', + trackingId, + variant = 'default', + size = 'md', + trackingProperties, + onClick, + disabled, + type = 'button', + ...props +}: ButtonProps) { + const { trackButtonClick } = useButtonTracking({ + category: trackingProperties?.category || 'interaction' + }); + + const handleClick = (event: React.MouseEvent) => { + // Rastreia o clique + trackButtonClick(trackingId, { + variant, + size, + ...trackingProperties, + }); + + // Chama o onClick original se existir + onClick?.(event); + }; + + const baseStyles = cn( + 'inline-flex items-center justify-center px-4 py-2', + 'text-sm font-medium', + 'rounded-md shadow-sm', + 'transition-colors duration-200', + 'disabled:opacity-50 disabled:cursor-not-allowed', + { + 'text-white bg-purple-600 hover:bg-purple-700': variant === 'primary' || variant === 'default', + 'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50': variant === 'secondary', + 'text-purple-600 bg-transparent hover:bg-purple-50': variant === 'ghost', + 'text-purple-600 bg-transparent hover:underline': variant === 'link', + 'text-purple-600 border border-purple-600 hover:bg-purple-50': variant === 'outline', + 'px-3 py-1.5 text-sm': size === 'sm', + 'px-4 py-2 text-base': size === 'md', + 'px-6 py-3 text-lg': size === 'lg', + }, + className + ); + return ( {children} diff --git a/src/hooks/useButtonTracking.ts b/src/hooks/useButtonTracking.ts new file mode 100644 index 0000000..703a559 --- /dev/null +++ b/src/hooks/useButtonTracking.ts @@ -0,0 +1,50 @@ +import { useRudderstack } from './useRudderstack'; + +interface ButtonTrackingOptions { + category?: string; + location?: string; +} + +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; + } + ) => { + track('button_clicked', { + button_id: buttonId, + category, + location, + timestamp: new Date().toISOString(), + ...properties, + // Informações da página + page_title: document.title, + page_url: window.location.href, + page_path: window.location.pathname, + // Informações do dispositivo/viewport + viewport_width: window.innerWidth, + viewport_height: window.innerHeight, + device_type: getDeviceType(), + }); + }; + + const getDeviceType = () => { + const width = window.innerWidth; + if (width < 768) return 'mobile'; + if (width < 1024) return 'tablet'; + return 'desktop'; + }; + + return { + trackButtonClick, + }; +} \ No newline at end of file diff --git a/src/hooks/useFormTracking.ts b/src/hooks/useFormTracking.ts new file mode 100644 index 0000000..da0bc16 --- /dev/null +++ b/src/hooks/useFormTracking.ts @@ -0,0 +1,83 @@ +import { useRudderstack } from './useRudderstack'; + +interface FormTrackingOptions { + formId: string; + formName: string; + category?: string; +} + +export function useFormTracking({ formId, formName, category = 'form' }: FormTrackingOptions) { + const { track } = useRudderstack(); + + const trackFormStarted = () => { + track('form_started', { + form_id: formId, + form_name: formName, + category, + }); + }; + + const trackFormStepCompleted = (step: string, isValid: boolean) => { + track('form_step_completed', { + form_id: formId, + form_name: formName, + category, + step, + is_valid: isValid, + }); + }; + + const trackFormSubmitted = (isValid: boolean, fields?: Record) => { + track('form_submitted', { + form_id: formId, + form_name: formName, + category, + is_valid: isValid, + ...fields, + }); + }; + + const trackFormError = (errorType: string, errorMessage: string, field?: string) => { + track('form_error', { + form_id: formId, + form_name: formName, + category, + error_type: errorType, + error_message: errorMessage, + field, + }); + }; + + const trackFormAbandoned = (step?: string) => { + track('form_abandoned', { + form_id: formId, + form_name: formName, + category, + step, + }); + }; + + const trackFieldInteraction = ( + fieldName: string, + interactionType: 'focus' | 'blur' | 'change', + value?: string + ) => { + track('form_field_interaction', { + form_id: formId, + form_name: formName, + category, + field_name: fieldName, + interaction_type: interactionType, + field_value: value, + }); + }; + + return { + trackFormStarted, + trackFormStepCompleted, + trackFormSubmitted, + trackFormError, + trackFormAbandoned, + trackFieldInteraction, + }; +} \ No newline at end of file diff --git a/src/hooks/useRudderstack.ts b/src/hooks/useRudderstack.ts new file mode 100644 index 0000000..6cfe385 --- /dev/null +++ b/src/hooks/useRudderstack.ts @@ -0,0 +1,55 @@ +interface RudderstackEvent { + event: string; + properties?: Record; +} + +interface UserTraits { + [key: string]: any; +} + +export function useRudderstack() { + const track = (eventName: string, properties?: Record) => { + if (window.rudderanalytics) { + window.rudderanalytics.track(eventName, properties); + } + }; + + const page = (name?: string, properties?: Record) => { + if (window.rudderanalytics) { + window.rudderanalytics.page(name, properties); + } + }; + + const identify = (userId: string, traits?: UserTraits) => { + if (window.rudderanalytics) { + window.rudderanalytics.identify(userId, traits); + } + }; + + 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 reset = () => { + if (window.rudderanalytics) { + window.rudderanalytics.reset(); + } + }; + + return { + track, + page, + identify, + group, + alias, + reset, + }; +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index 24c5a02..8f5bfcd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,11 +6,16 @@ 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 './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,6 +59,10 @@ function Root() { return ( + diff --git a/src/routes.tsx b/src/routes.tsx index 1655b02..cc93cc9 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -32,11 +32,21 @@ import { TestWordHighlighter } from './pages/TestWordHighlighter'; import { ExercisePage } from './pages/student-dashboard/ExercisePage'; import { EvidenceBased } from './pages/landing/EvidenceBased'; import { TextSalesLetter } from './pages/landing/TextSalesLetter'; +import { PageTracker } from './components/analytics/PageTracker'; + +function RootLayout({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} export const router = createBrowserRouter([ { path: '/', - element: , + element: , }, { path: '/teste',